🚧 Add board, Improve header

This commit is contained in:
Meier Lukas
2023-07-30 01:09:10 +02:00
parent b19d489a4c
commit e1aaf82602
9 changed files with 300 additions and 46 deletions

View File

@@ -74,7 +74,7 @@ model UserSettings {
userId String userId String
colorScheme String @default("environment") // environment, light, dark colorScheme String @default("environment") // environment, light, dark
language String @default("en") language String @default("en")
defaultDashboard String @default("default") defaultBoard String @default("default")
searchTemplate String @default("https://google.com/search?q=%s") searchTemplate String @default("https://google.com/search?q=%s")
openSearchInNewTab Boolean @default(true) openSearchInNewTab Boolean @default(true)
disablePingPulse Boolean @default(false) disablePingPulse Boolean @default(false)

View File

@@ -5,12 +5,16 @@ import { useConfigContext } from '../../config/provider';
export function Background() { export function Background() {
const { config } = useConfigContext(); const { config } = useConfigContext();
if (!config?.settings.customization.backgroundImageUrl) {
return null;
}
return ( return (
<Global <Global
styles={{ styles={{
body: { body: {
minHeight: '100vh', minHeight: '100vh',
backgroundImage: `url('${config?.settings.customization.backgroundImageUrl}')` || '', backgroundImage: `url('${config?.settings.customization.backgroundImageUrl}')`,
backgroundPosition: 'center center', backgroundPosition: 'center center',
backgroundSize: 'cover', backgroundSize: 'cover',
backgroundRepeat: 'no-repeat', backgroundRepeat: 'no-repeat',

View File

@@ -2,8 +2,8 @@ import { AppShell, createStyles } from '@mantine/core';
import { useConfigContext } from '../../config/provider'; import { useConfigContext } from '../../config/provider';
import { Background } from './Background'; import { Background } from './Background';
import { Head } from './Meta/Head';
import { Header } from './header/Header'; import { Header } from './header/Header';
import { Head } from './header/Meta/Head';
const useStyles = createStyles(() => ({})); const useStyles = createStyles(() => ({}));

View File

@@ -2,7 +2,7 @@
import NextHead from 'next/head'; import NextHead from 'next/head';
import React from 'react'; import React from 'react';
import { useConfigContext } from '../../../../config/provider'; import { useConfigContext } from '../../../config/provider';
import { SafariStatusBarStyle } from './SafariStatusBarStyle'; import { SafariStatusBarStyle } from './SafariStatusBarStyle';
export function Head() { export function Head() {

View File

@@ -1,13 +1,19 @@
import { AppShell, useMantineTheme } from '@mantine/core'; import { AppShell, clsx, useMantineTheme } from '@mantine/core';
import { useConfigContext } from '~/config/provider';
import { Background } from './Background';
import { Head } from './Meta/Head';
import { MainHeader } from './new-header/Header'; import { MainHeader } from './new-header/Header';
type MainLayoutProps = { type MainLayoutProps = {
headerActions?: React.ReactNode;
children: React.ReactNode; children: React.ReactNode;
}; };
export const MainLayout = ({ children }: MainLayoutProps) => { export const MainLayout = ({ headerActions, children }: MainLayoutProps) => {
const { config } = useConfigContext();
const theme = useMantineTheme(); const theme = useMantineTheme();
return ( return (
<AppShell <AppShell
styles={{ styles={{
@@ -15,9 +21,13 @@ export const MainLayout = ({ children }: MainLayoutProps) => {
background: theme.colorScheme === 'dark' ? theme.colors.dark[6] : theme.colors.gray[1], background: theme.colorScheme === 'dark' ? theme.colors.dark[6] : theme.colors.gray[1],
}, },
}} }}
header={<MainHeader />} header={<MainHeader headerActions={headerActions} />}
className="dashboard-app-shell"
> >
<Head />
<Background />
{children} {children}
<style>{clsx(config?.settings.customization.customCss)}</style>
</AppShell> </AppShell>
); );
}; };

View File

@@ -9,25 +9,29 @@ import {
IconSun, IconSun,
IconUserCog, IconUserCog,
} from '@tabler/icons-react'; } from '@tabler/icons-react';
import { useQuery } from '@tanstack/react-query';
import { User } from 'next-auth'; import { User } from 'next-auth';
import { signOut, useSession } from 'next-auth/react'; import { signOut, useSession } from 'next-auth/react';
import Link from 'next/link'; import Link from 'next/link';
import { forwardRef } from 'react'; import { forwardRef } from 'react';
import { AboutModal } from '~/components/Dashboard/Modals/AboutModal/AboutModal'; import { AboutModal } from '~/components/Dashboard/Modals/AboutModal/AboutModal';
import { useColorScheme } from '~/hooks/use-colorscheme'; import { useColorScheme } from '~/hooks/use-colorscheme';
import { usePackageAttributesStore } from '~/tools/client/zustands/usePackageAttributesStore';
import { REPO_URL } from '../../../../data/constants';
export const AvatarMenu = () => { export const AvatarMenu = () => {
const newVersionAvailable = '0.13.0';
const [aboutModalOpened, aboutModal] = useDisclosure(false); const [aboutModalOpened, aboutModal] = useDisclosure(false);
const { data: sessionData } = useSession(); const { data: sessionData } = useSession();
const { colorScheme, toggleColorScheme } = useColorScheme(); const { colorScheme, toggleColorScheme } = useColorScheme();
const newVersionAvailable = useNewVersionAvailable();
const Icon = colorScheme === 'dark' ? IconSun : IconMoonStars; const Icon = colorScheme === 'dark' ? IconSun : IconMoonStars;
return ( return (
<> <>
<UnstyledButton> <UnstyledButton>
<Menu> <Menu width={192}>
<Menu.Target> <Menu.Target>
<CurrentUserAvatar user={sessionData?.user ?? null} /> <CurrentUserAvatar user={sessionData?.user ?? null} />
</Menu.Target> </Menu.Target>
@@ -88,6 +92,7 @@ export const AvatarMenu = () => {
type CurrentUserAvatarProps = { type CurrentUserAvatarProps = {
user: User | null; user: User | null;
}; };
const CurrentUserAvatar = forwardRef<HTMLDivElement, CurrentUserAvatarProps>( const CurrentUserAvatar = forwardRef<HTMLDivElement, CurrentUserAvatarProps>(
({ user, ...others }, ref) => { ({ user, ...others }, ref) => {
const { primaryColor } = useMantineTheme(); const { primaryColor } = useMantineTheme();
@@ -99,3 +104,15 @@ const CurrentUserAvatar = forwardRef<HTMLDivElement, CurrentUserAvatarProps>(
); );
} }
); );
const useNewVersionAvailable = () => {
const { attributes } = usePackageAttributesStore();
const { data } = useQuery({
queryKey: ['github/latest'],
cacheTime: 1000 * 60 * 60 * 24,
staleTime: 1000 * 60 * 60 * 5,
queryFn: () =>
fetch(`https://api.github.com/repos/${REPO_URL}/releases/latest`).then((res) => res.json()),
});
return data?.tag_name > `v${attributes.packageVersion}` ? data?.tag_name : undefined;
};

View File

@@ -1,24 +1,8 @@
import { import { Box, Flex, Group, Header, Text, UnstyledButton, useMantineTheme } from '@mantine/core';
Anchor, import { useMediaQuery } from '@mantine/hooks';
Avatar, import { IconAlertTriangle } from '@tabler/icons-react';
Box,
Flex,
Group,
Header,
Menu,
Text,
TextInput,
UnstyledButton,
} from '@mantine/core';
import {
IconAlertTriangle,
IconDashboard,
IconLogout,
IconSun,
IconUserSearch,
} from '@tabler/icons-react';
import { signOut } from 'next-auth/react';
import Link from 'next/link'; import Link from 'next/link';
import { useScreenLargerThan } from '~/hooks/useScreenLargerThan';
import { Logo } from '../Logo'; import { Logo } from '../Logo';
import { AvatarMenu } from './AvatarMenu'; import { AvatarMenu } from './AvatarMenu';
@@ -27,21 +11,33 @@ import { Search } from './search';
type MainHeaderProps = { type MainHeaderProps = {
logoHref?: string; logoHref?: string;
showExperimental?: boolean; showExperimental?: boolean;
headerActions?: React.ReactNode;
}; };
export const MainHeader = ({ showExperimental = false, logoHref = '/' }: MainHeaderProps) => { export const MainHeader = ({
const headerHeight = showExperimental ? 60 + 30 : 60; showExperimental = false,
logoHref = '/',
headerActions,
}: MainHeaderProps) => {
const { breakpoints } = useMantineTheme();
const isSmallerThanMd = useMediaQuery(`(max-width: ${breakpoints.sm})`);
const experimentalHeaderNoteHeight = isSmallerThanMd ? 50 : 30;
const headerHeight = showExperimental ? 60 + experimentalHeaderNoteHeight : 60;
return ( return (
<Header height={headerHeight} pb="sm" pt={0}> <Header height={headerHeight} pb="sm" pt={0}>
<ExperimentalHeaderNote visible={showExperimental} /> <ExperimentalHeaderNote visible={showExperimental} height={experimentalHeaderNoteHeight} />
<Group spacing="xl" mt="xs" px="md" position="apart" noWrap> <Group spacing="xl" mt="xs" px="md" position="apart" noWrap>
<UnstyledButton component={Link} href={logoHref}> <UnstyledButton component={Link} href={logoHref} style={{ flex: 1 }}>
<Logo /> <Logo />
</UnstyledButton> </UnstyledButton>
<Search /> <Search />
<Group noWrap> <Group noWrap style={{ flex: 1 }} position="right">
<Group noWrap spacing={8}>
{headerActions}
</Group>
<AvatarMenu /> <AvatarMenu />
</Group> </Group>
</Group> </Group>
@@ -50,16 +46,17 @@ export const MainHeader = ({ showExperimental = false, logoHref = '/' }: MainHea
}; };
type ExperimentalHeaderNoteProps = { type ExperimentalHeaderNoteProps = {
height?: 30 | 50;
visible?: boolean; visible?: boolean;
}; };
const ExperimentalHeaderNote = ({ visible = false }: ExperimentalHeaderNoteProps) => { const ExperimentalHeaderNote = ({ visible = false, height = 30 }: ExperimentalHeaderNoteProps) => {
if (!visible) return null; if (!visible) return null;
return ( return (
<Box bg="red" h={30} p={3} px={6}> <Box bg="red" h={height} p={3} px={6}>
<Flex h="100%" align="center" columnGap={7}> <Flex h="100%" align="center" columnGap={7}>
<IconAlertTriangle color="white" size="1rem" /> <IconAlertTriangle color="white" size="1rem" style={{ minWidth: '1rem' }} />
<Text color="white"> <Text color="white" lineClamp={height === 30 ? 1 : 2}>
This is an experimental feature of Homarr. Please report any issues to the official Homarr This is an experimental feature of Homarr. Please report any issues to the official Homarr
team. team.
</Text> </Text>

View File

@@ -1,18 +1,244 @@
import { Title } from '@mantine/core'; import { Button, ButtonProps, Text, Title, Tooltip } from '@mantine/core';
import { GetServerSideProps } from 'next'; import { useHotkeys, useWindowEvent } from '@mantine/hooks';
import { openContextModal } from '@mantine/modals';
import { hideNotification, showNotification } from '@mantine/notifications';
import { IconApps, IconBrandDocker, IconEditCircle, IconEditCircleOff } from '@tabler/icons-react';
import Consola from 'consola';
import { GetServerSideProps, InferGetServerSidePropsType } from 'next';
import { useSession } from 'next-auth/react';
import { SSRConfig, Trans, useTranslation } from 'next-i18next';
import Link from 'next/link';
import { Dashboard } from '~/components/Dashboard/Dashboard';
import { useEditModeStore } from '~/components/Dashboard/Views/useEditModeStore';
import { useNamedWrapperColumnCount } from '~/components/Dashboard/Wrappers/gridstack/store';
import { MainLayout } from '~/components/layout/main'; import { MainLayout } from '~/components/layout/main';
import { useCardStyles } from '~/components/layout/useCardStyles';
import { useInitConfig } from '~/config/init';
import { useConfigContext } from '~/config/provider';
import { getServerAuthSession } from '~/server/auth';
import { prisma } from '~/server/db';
import { getFrontendConfig } from '~/tools/config/getFrontendConfig';
import { getServerSideTranslations } from '~/tools/server/getServerSideTranslations';
import { dashboardNamespaces } from '~/tools/server/translation-namespaces';
import { ConfigType } from '~/types/config';
import { api } from '~/utils/api';
export default function BoardPage({
config: initialConfig,
}: InferGetServerSidePropsType<typeof getServerSideProps>) {
useInitConfig(initialConfig);
export default function BoardPage() {
return ( return (
<MainLayout> <MainLayout headerActions={<HeaderActions />}>
<Title order={1}>BoardPage</Title> <Dashboard />
</MainLayout> </MainLayout>
); );
} }
export const getServerSideProps: GetServerSideProps = async () => { type BoardGetServerSideProps = {
console.log('getServerSideProps'); config: ConfigType;
_nextI18Next?: SSRConfig['_nextI18Next'];
};
export const getServerSideProps: GetServerSideProps<BoardGetServerSideProps> = async (ctx) => {
const session = await getServerAuthSession(ctx);
const currentUserSettings = await prisma.userSettings.findFirst({
where: {
userId: session?.user?.id,
},
});
const translations = await getServerSideTranslations(
dashboardNamespaces,
ctx.locale,
ctx.req,
ctx.res
);
const boardName = currentUserSettings?.defaultBoard ?? 'default';
const config = await getFrontendConfig(boardName as string);
return { return {
props: {}, props: {
config,
...translations,
},
}; };
}; };
const HeaderActions = () => {
const { data: sessionData } = useSession();
if (!sessionData?.user?.isAdmin) return null;
return (
<>
<DockerButton />
<ToggleEditModeButton />
</>
);
};
const DockerButton = () => {
const { t } = useTranslation('modules/docker');
return (
<Tooltip label={t('actionIcon.tooltip')}>
<HeaderActionButton component={Link} href="/docker">
<IconBrandDocker size={20} stroke={1.5} />
</HeaderActionButton>
</Tooltip>
);
};
type SpecificLinkProps = {
component: typeof Link;
href: string;
};
type SpecificButtonProps = {
onClick: HTMLButtonElement['onclick'];
};
type HeaderActionButtonProps = Omit<ButtonProps, 'variant' | 'className' | 'h' | 'w' | 'px'> &
(SpecificLinkProps | SpecificButtonProps);
const HeaderActionButton = ({ children, ...props }: HeaderActionButtonProps) => {
const { classes } = useCardStyles(true);
const buttonProps: ButtonProps = {
variant: 'default',
className: classes.card,
h: 38,
w: 38,
px: 0,
...props,
};
if ('component' in props) {
return (
<Button component={props.component} href={props.href} {...buttonProps}>
{children}
</Button>
);
}
return <Button {...buttonProps}>{children}</Button>;
};
const beforeUnloadEventText = 'Exit the edit mode to save your changes';
const editModeNotificationId = 'toggle-edit-mode';
const ToggleEditModeButton = () => {
const { enabled, toggleEditMode } = useEditModeStore();
const { classes } = useCardStyles(true);
const { config, name: configName } = useConfigContext();
const { mutateAsync: saveConfig } = api.config.save.useMutation();
const namedWrapperColumnCount = useNamedWrapperColumnCount();
const { t } = useTranslation(['layout/header/actions/toggle-edit-mode', 'common']);
const translatedSize =
namedWrapperColumnCount !== null
? t(`common:breakPoints.${namedWrapperColumnCount}`)
: t('common:loading');
useHotkeys([['mod+E', toggleEditMode]]);
useWindowEvent('beforeunload', (event: BeforeUnloadEvent) => {
if (enabled) {
// eslint-disable-next-line no-param-reassign
event.returnValue = beforeUnloadEventText;
return beforeUnloadEventText;
}
return undefined;
});
const save = async () => {
toggleEditMode();
if (!config || !configName) return;
await saveConfig({ name: configName, config });
Consola.log('Saved config to server', configName);
hideNotification(editModeNotificationId);
};
const enableEditMode = () => {
toggleEditMode();
showNotification({
styles: (theme) => ({
root: {
backgroundColor: theme.colors.orange[7],
borderColor: theme.colors.orange[7],
'&::before': { backgroundColor: theme.white },
},
title: { color: theme.white },
description: { color: theme.white },
closeButton: {
color: theme.white,
'&:hover': { backgroundColor: theme.colors.orange[7] },
},
}),
radius: 'md',
id: 'toggle-edit-mode',
autoClose: 10000,
title: (
<Title order={4}>
<Trans
i18nKey="layout/header/actions/toggle-edit-mode:popover.title"
values={{ size: translatedSize }}
components={{
1: (
<Text
component="a"
style={{ color: 'inherit', textDecoration: 'underline' }}
href="https://homarr.dev/docs/customizations/layout"
target="_blank"
/>
),
}}
/>
</Title>
),
message: <Trans i18nKey="layout/header/actions/toggle-edit-mode:popover.text" />,
});
};
if (enabled) {
return (
<Button.Group>
<Tooltip label={t('button.disabled')}>
<HeaderActionButton onClick={save}>
<IconEditCircleOff size={20} stroke={1.5} />
</HeaderActionButton>
</Tooltip>
<AddElementButton />
</Button.Group>
);
}
return (
<Tooltip label={t('button.disabled')}>
<HeaderActionButton onClick={enableEditMode}>
<IconEditCircle size={20} stroke={1.5} />
</HeaderActionButton>
</Tooltip>
);
};
const AddElementButton = () => {
const { t } = useTranslation('layout/element-selector/selector');
const { classes } = useCardStyles(true);
return (
<Tooltip label={t('actionIcon.tooltip')}>
<HeaderActionButton
onClick={() =>
openContextModal({
modal: 'selectElement',
title: t('modal.title'),
size: 'xl',
innerProps: {},
})
}
>
<IconApps size={20} stroke={1.5} />
</HeaderActionButton>
</Tooltip>
);
};