🚧 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
colorScheme String @default("environment") // environment, light, dark
language String @default("en")
defaultDashboard String @default("default")
defaultBoard String @default("default")
searchTemplate String @default("https://google.com/search?q=%s")
openSearchInNewTab Boolean @default(true)
disablePingPulse Boolean @default(false)

View File

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

View File

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

View File

@@ -2,7 +2,7 @@
import NextHead from 'next/head';
import React from 'react';
import { useConfigContext } from '../../../../config/provider';
import { useConfigContext } from '../../../config/provider';
import { SafariStatusBarStyle } from './SafariStatusBarStyle';
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';
type MainLayoutProps = {
headerActions?: React.ReactNode;
children: React.ReactNode;
};
export const MainLayout = ({ children }: MainLayoutProps) => {
export const MainLayout = ({ headerActions, children }: MainLayoutProps) => {
const { config } = useConfigContext();
const theme = useMantineTheme();
return (
<AppShell
styles={{
@@ -15,9 +21,13 @@ export const MainLayout = ({ children }: MainLayoutProps) => {
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}
<style>{clsx(config?.settings.customization.customCss)}</style>
</AppShell>
);
};

View File

@@ -9,25 +9,29 @@ import {
IconSun,
IconUserCog,
} from '@tabler/icons-react';
import { useQuery } from '@tanstack/react-query';
import { User } from 'next-auth';
import { signOut, useSession } from 'next-auth/react';
import Link from 'next/link';
import { forwardRef } from 'react';
import { AboutModal } from '~/components/Dashboard/Modals/AboutModal/AboutModal';
import { useColorScheme } from '~/hooks/use-colorscheme';
import { usePackageAttributesStore } from '~/tools/client/zustands/usePackageAttributesStore';
import { REPO_URL } from '../../../../data/constants';
export const AvatarMenu = () => {
const newVersionAvailable = '0.13.0';
const [aboutModalOpened, aboutModal] = useDisclosure(false);
const { data: sessionData } = useSession();
const { colorScheme, toggleColorScheme } = useColorScheme();
const newVersionAvailable = useNewVersionAvailable();
const Icon = colorScheme === 'dark' ? IconSun : IconMoonStars;
return (
<>
<UnstyledButton>
<Menu>
<Menu width={192}>
<Menu.Target>
<CurrentUserAvatar user={sessionData?.user ?? null} />
</Menu.Target>
@@ -88,6 +92,7 @@ export const AvatarMenu = () => {
type CurrentUserAvatarProps = {
user: User | null;
};
const CurrentUserAvatar = forwardRef<HTMLDivElement, CurrentUserAvatarProps>(
({ user, ...others }, ref) => {
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 {
Anchor,
Avatar,
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 { Box, Flex, Group, Header, Text, UnstyledButton, useMantineTheme } from '@mantine/core';
import { useMediaQuery } from '@mantine/hooks';
import { IconAlertTriangle } from '@tabler/icons-react';
import Link from 'next/link';
import { useScreenLargerThan } from '~/hooks/useScreenLargerThan';
import { Logo } from '../Logo';
import { AvatarMenu } from './AvatarMenu';
@@ -27,21 +11,33 @@ import { Search } from './search';
type MainHeaderProps = {
logoHref?: string;
showExperimental?: boolean;
headerActions?: React.ReactNode;
};
export const MainHeader = ({ showExperimental = false, logoHref = '/' }: MainHeaderProps) => {
const headerHeight = showExperimental ? 60 + 30 : 60;
export const MainHeader = ({
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 (
<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>
<UnstyledButton component={Link} href={logoHref}>
<UnstyledButton component={Link} href={logoHref} style={{ flex: 1 }}>
<Logo />
</UnstyledButton>
<Search />
<Group noWrap>
<Group noWrap style={{ flex: 1 }} position="right">
<Group noWrap spacing={8}>
{headerActions}
</Group>
<AvatarMenu />
</Group>
</Group>
@@ -50,16 +46,17 @@ export const MainHeader = ({ showExperimental = false, logoHref = '/' }: MainHea
};
type ExperimentalHeaderNoteProps = {
height?: 30 | 50;
visible?: boolean;
};
const ExperimentalHeaderNote = ({ visible = false }: ExperimentalHeaderNoteProps) => {
const ExperimentalHeaderNote = ({ visible = false, height = 30 }: ExperimentalHeaderNoteProps) => {
if (!visible) return null;
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}>
<IconAlertTriangle color="white" size="1rem" />
<Text color="white">
<IconAlertTriangle color="white" size="1rem" style={{ minWidth: '1rem' }} />
<Text color="white" lineClamp={height === 30 ? 1 : 2}>
This is an experimental feature of Homarr. Please report any issues to the official Homarr
team.
</Text>

View File

@@ -1,18 +1,244 @@
import { Title } from '@mantine/core';
import { GetServerSideProps } from 'next';
import { Button, ButtonProps, Text, Title, Tooltip } from '@mantine/core';
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 { 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 (
<MainLayout>
<Title order={1}>BoardPage</Title>
<MainLayout headerActions={<HeaderActions />}>
<Dashboard />
</MainLayout>
);
}
export const getServerSideProps: GetServerSideProps = async () => {
console.log('getServerSideProps');
type BoardGetServerSideProps = {
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 {
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>
);
};