Merge pull request #1539 from ajnart/about-page

Turn about modal into a static page
This commit is contained in:
Thomas Camlong
2023-11-07 09:57:25 +01:00
committed by GitHub
13 changed files with 3498 additions and 565 deletions

View File

@@ -5,6 +5,7 @@ import {
Flex,
Footer,
Group,
Indicator,
NavLink,
Navbar,
Paper,
@@ -20,6 +21,8 @@ import {
IconBrandGithub,
IconGitFork,
IconHome,
IconInfoCircle,
IconInfoSmall,
IconLayoutDashboard,
IconMailForward,
IconQuestionMark,
@@ -28,6 +31,7 @@ import {
IconUsers,
TablerIconsProps,
} from '@tabler/icons-react';
import { useQuery } from '@tanstack/react-query';
import { useSession } from 'next-auth/react';
import { useTranslation } from 'next-i18next';
import Image from 'next/image';
@@ -36,7 +40,9 @@ import { useRouter } from 'next/router';
import { ReactNode, RefObject, forwardRef } from 'react';
import { useScreenLargerThan } from '~/hooks/useScreenLargerThan';
import { usePackageAttributesStore } from '~/tools/client/zustands/usePackageAttributesStore';
import { ConditionalWrapper } from '~/utils/security';
import { REPO_URL } from '../../../../data/constants';
import { type navigation } from '../../../../public/locales/en/layout/manage.json';
import { MainHeader } from '../header/Header';
@@ -46,7 +52,18 @@ interface ManageLayoutProps {
export const ManageLayout = ({ children }: ManageLayoutProps) => {
const packageVersion = usePackageAttributesStore((x) => x.attributes.packageVersion);
const theme = useMantineTheme();
const { data: newVersion } = 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`, {
cache: 'force-cache',
}).then((res) => res.json()),
});
const { attributes } = usePackageAttributesStore();
const newVersionAvailable =
newVersion?.tag_name > `v${attributes.packageVersion}` ? newVersion?.tag_name : undefined;
const screenLargerThanMd = useScreenLargerThan('md');
@@ -56,6 +73,162 @@ export const ManageLayout = ({ children }: ManageLayoutProps) => {
const data = useSession();
const isAdmin = data.data?.user.isAdmin ?? false;
const navigationLinks: NavigationLinks = {
home: {
icon: IconHome,
href: '/manage',
},
boards: {
icon: IconLayoutDashboard,
href: '/manage/boards',
},
users: {
icon: IconUser,
onlyAdmin: true,
items: {
manage: {
icon: IconUsers,
href: '/manage/users',
},
invites: {
icon: IconMailForward,
href: '/manage/users/invites',
},
},
},
tools: {
icon: IconTool,
onlyAdmin: true,
items: {
docker: {
icon: IconBrandDocker,
href: '/manage/tools/docker',
},
},
},
help: {
icon: IconQuestionMark,
items: {
documentation: {
icon: IconBook2,
href: 'https://homarr.dev/docs/about',
target: '_blank',
},
report: {
icon: IconBrandGithub,
href: 'https://github.com/ajnart/homarr/issues/new/choose',
target: '_blank',
},
discord: {
icon: IconBrandDiscord,
href: 'https://discord.com/invite/aCsmEV5RgA',
target: '_blank',
},
contribute: {
icon: IconGitFork,
href: 'https://github.com/ajnart/homarr',
target: '_blank',
},
},
},
about: {
icon: IconInfoSmall,
displayUpdate: newVersionAvailable !== undefined,
href: '/manage/about',
},
};
type CustomNavigationLinkProps = {
name: keyof typeof navigationLinks;
navigationLink: (typeof navigationLinks)[keyof typeof navigationLinks];
};
const CustomNavigationLink = forwardRef<
HTMLAnchorElement | HTMLButtonElement,
CustomNavigationLinkProps
>(({ name, navigationLink }, ref) => {
const { t } = useTranslation('layout/manage');
const router = useRouter();
const commonProps = {
label: t(`navigation.${name}.title`),
icon: (
<ConditionalWrapper
condition={navigationLink.displayUpdate === true}
wrapper={(children) => (
<Indicator withBorder offset={2} color="blue" processing size={12}>
{children}
</Indicator>
)}
>
<ThemeIcon size="md" variant="light" color="red">
<navigationLink.icon size={16} />
</ThemeIcon>
</ConditionalWrapper>
),
defaultOpened: false,
};
if ('href' in navigationLink) {
const isActive = router.pathname.endsWith(navigationLink.href);
return (
<NavLink
{...commonProps}
ref={ref as RefObject<HTMLAnchorElement>}
component={Link}
href={navigationLink.href}
active={isActive}
/>
);
}
const isAnyActive = Object.entries(navigationLink.items)
.map(([_, item]) => item.href)
.some((href) => router.pathname.endsWith(href));
return (
<NavLink
{...commonProps}
defaultOpened={isAnyActive}
ref={ref as RefObject<HTMLButtonElement>}
>
{Object.entries(navigationLink.items).map(([itemName, item], index) => {
const commonItemProps = {
label: t(`navigation.${name}.items.${itemName}`),
icon: <item.icon size={16} />,
href: item.href,
};
const matchesActive = router.pathname.endsWith(item.href);
if (item.href.startsWith('http')) {
return (
<NavLink
{...commonItemProps}
active={matchesActive}
target={item.target}
key={index}
component="a"
/>
);
}
return (
<NavLink {...commonItemProps} active={matchesActive} component={Link} key={index} />
);
})}
</NavLink>
);
});
type NavigationLinks = {
[key in keyof typeof navigation]: (typeof navigation)[key] extends {
items: Record<string, string>;
}
? NavigationLinkItems<(typeof navigation)[key]['items']>
: NavigationLinkHref;
};
const navigationLinkComponents = Object.entries(navigationLinks).map(([name, navigationLink]) => {
if (navigationLink.onlyAdmin && !isAdmin) {
return null;
@@ -77,11 +250,6 @@ export const ManageLayout = ({ children }: ManageLayoutProps) => {
return (
<>
<AppShell
styles={{
root: {
background: theme.colorScheme === 'dark' ? theme.colors.dark[6] : theme.colors.gray[1],
},
}}
navbar={
<Navbar width={{ base: !screenLargerThanMd ? 0 : 220 }} hidden={!screenLargerThanMd}>
<Navbar.Section pt="xs" grow>
@@ -108,9 +276,7 @@ export const ManageLayout = ({ children }: ManageLayoutProps) => {
</Footer>
}
>
<Paper p="xl" mih="100%" withBorder>
{children}
</Paper>
{children}
</AppShell>
<Drawer
opened={burgerMenuOpen}
@@ -132,146 +298,12 @@ type NavigationLinkHref = {
href: string;
target?: '_self' | '_blank';
onlyAdmin?: boolean;
displayUpdate?: boolean;
};
type NavigationLinkItems<TItemsObject> = {
icon: Icon;
items: Record<keyof TItemsObject, NavigationLinkHref>;
onlyAdmin?: boolean;
};
type CustomNavigationLinkProps = {
name: keyof typeof navigationLinks;
navigationLink: (typeof navigationLinks)[keyof typeof navigationLinks];
};
const CustomNavigationLink = forwardRef<
HTMLAnchorElement | HTMLButtonElement,
CustomNavigationLinkProps
>(({ name, navigationLink }, ref) => {
const { t } = useTranslation('layout/manage');
const router = useRouter();
const commonProps = {
label: t(`navigation.${name}.title`),
icon: (
<ThemeIcon size="md" variant="light" color="red">
<navigationLink.icon size={16} />
</ThemeIcon>
),
defaultOpened: false,
};
if ('href' in navigationLink) {
const isActive = router.pathname.endsWith(navigationLink.href);
return (
<NavLink
{...commonProps}
ref={ref as RefObject<HTMLAnchorElement>}
component={Link}
href={navigationLink.href}
active={isActive}
/>
);
}
const isAnyActive = Object.entries(navigationLink.items)
.map(([_, item]) => item.href)
.some((href) => router.pathname.endsWith(href));
return (
<NavLink {...commonProps} defaultOpened={isAnyActive} ref={ref as RefObject<HTMLButtonElement>}>
{Object.entries(navigationLink.items).map(([itemName, item], index) => {
const commonItemProps = {
label: t(`navigation.${name}.items.${itemName}`),
icon: <item.icon size={16} />,
href: item.href,
};
const matchesActive = router.pathname.endsWith(item.href);
if (item.href.startsWith('http')) {
return (
<NavLink
{...commonItemProps}
active={matchesActive}
target={item.target}
key={index}
component="a"
/>
);
}
return <NavLink {...commonItemProps} active={matchesActive} component={Link} key={index} />;
})}
</NavLink>
);
});
type NavigationLinks = {
[key in keyof typeof navigation]: (typeof navigation)[key] extends {
items: Record<string, string>;
}
? NavigationLinkItems<(typeof navigation)[key]['items']>
: NavigationLinkHref;
};
const navigationLinks: NavigationLinks = {
home: {
icon: IconHome,
href: '/manage',
},
boards: {
icon: IconLayoutDashboard,
href: '/manage/boards',
},
users: {
icon: IconUser,
onlyAdmin: true,
items: {
manage: {
icon: IconUsers,
href: '/manage/users',
},
invites: {
icon: IconMailForward,
href: '/manage/users/invites',
},
},
},
tools: {
icon: IconTool,
onlyAdmin: true,
items: {
docker: {
icon: IconBrandDocker,
href: '/manage/tools/docker',
},
},
},
help: {
icon: IconQuestionMark,
items: {
documentation: {
icon: IconBook2,
href: 'https://homarr.dev/docs/about',
target: '_blank',
},
report: {
icon: IconBrandGithub,
href: 'https://github.com/ajnart/homarr/issues/new/choose',
target: '_blank',
},
discord: {
icon: IconBrandDiscord,
href: 'https://discord.com/invite/aCsmEV5RgA',
target: '_blank',
},
contribute: {
icon: IconGitFork,
href: 'https://github.com/ajnart/homarr',
target: '_blank',
},
},
},
displayUpdate?: boolean;
};

View File

@@ -1,321 +0,0 @@
import {
Accordion,
ActionIcon,
Anchor,
Badge,
Button,
Grid,
Group,
HoverCard,
Image,
Kbd,
Modal,
Table,
Text,
Title,
createStyles,
} from '@mantine/core';
import {
IconAnchor,
IconBrandDiscord,
IconBrandGithub,
IconFile,
IconKey,
IconLanguage,
IconSchema,
IconVersions,
IconVocabulary,
IconWorldWww,
} from '@tabler/icons-react';
import { motion } from 'framer-motion';
import { InitOptions } from 'i18next';
import { Trans, i18n, useTranslation } from 'next-i18next';
import { ReactNode } from 'react';
import { useConfigContext } from '~/config/provider';
import { useConfigStore } from '~/config/store';
import { usePackageAttributesStore } from '~/tools/client/zustands/usePackageAttributesStore';
import { useColorTheme } from '~/tools/color';
import { usePrimaryGradient } from '../../Common/useGradient';
import Credits from './Credits';
import Tip from './Tip';
interface AboutModalProps {
opened: boolean;
closeModal: () => void;
newVersionAvailable?: string;
}
export const AboutModal = ({ opened, closeModal, newVersionAvailable }: AboutModalProps) => {
const { classes } = useStyles();
const colorGradiant = usePrimaryGradient();
const informations = useInformationTableItems(newVersionAvailable);
const { t } = useTranslation(['common', 'layout/modals/about']);
const keybinds = [
{ key: 'Mod + J', shortcut: t('layout/modals/about:actions.toggleTheme') },
{ key: 'Mod + K', shortcut: t('layout/modals/about:actions.focusSearchBar') },
{ key: 'Mod + B', shortcut: t('layout/modals/about:actions.openDocker') },
{ key: 'Mod + E', shortcut: t('layout/modals/about:actions.toggleEdit') },
];
const rows = keybinds.map((element) => (
<tr key={element.key}>
<td>
<Kbd>{element.key}</Kbd>
</td>
<td>
<Text>{element.shortcut}</Text>
</td>
</tr>
));
return (
<Modal
onClose={() => closeModal()}
opened={opened}
title={
<Group spacing="sm">
<Image alt="Homarr logo" src="/imgs/logo/logo.png" width={30} height={30} fit="contain" />
<Title order={3} variant="gradient" gradient={colorGradiant}>
{t('about')} Homarr
</Title>
</Group>
}
size="xl"
>
<Text mb="lg">
<Trans i18nKey="layout/modals/about:description" />
</Text>
<Table mb="lg" highlightOnHover withBorder>
<tbody>
{informations.map((item, index) => (
<tr key={index}>
<td>
<Group spacing="xs">
<ActionIcon className={classes.informationIcon} variant="default">
{item.icon}
</ActionIcon>
<Text>
<Trans
i18nKey={`layout/modals/about:metrics.${item.label}`}
components={{ b: <b /> }}
/>
</Text>
</Group>
</td>
<td className={classes.informationTableColumn} style={{ maxWidth: 200 }}>
{item.content}
</td>
</tr>
))}
</tbody>
</Table>
<Accordion mb={5} variant="contained" radius="md">
<Accordion.Item value="keybinds">
<Accordion.Control icon={<IconKey size={20} />}>
{t('layout/modals/about:keybinds')}
</Accordion.Control>
<Accordion.Panel>
<Table mb={5}>
<thead>
<tr>
<th>{t('layout/modals/about:key')}</th>
<th>{t('layout/modals/about:action')}</th>
</tr>
</thead>
<tbody>{rows}</tbody>
</Table>
<Tip>{t('layout/modals/about:tip')}</Tip>
</Accordion.Panel>
</Accordion.Item>
</Accordion>
<Title order={6} mb="xs" align="center">
{t('layout/modals/about:contact')}
</Title>
<Grid grow>
<Grid.Col md={4} xs={12}>
<Button
component="a"
href="https://github.com/ajnart/homarr"
target="_blank"
leftIcon={<IconBrandGithub size={20} />}
variant="default"
fullWidth
>
GitHub
</Button>
</Grid.Col>
<Grid.Col md={4} xs={12}>
<Button
component="a"
href="https://homarr.dev/"
target="_blank"
leftIcon={<IconWorldWww size={20} />}
variant="default"
fullWidth
>
{t('layout/modals/about:documentation')}
</Button>
</Grid.Col>
<Grid.Col md={4} xs={12}>
<Button
component="a"
href="https://discord.gg/aCsmEV5RgA"
target="_blank"
leftIcon={<IconBrandDiscord size={20} />}
variant="default"
fullWidth
>
Discord
</Button>
</Grid.Col>
</Grid>
<Credits />
</Modal>
);
};
interface InformationTableItem {
icon: ReactNode;
label: string;
content: ReactNode;
}
interface ExtendedInitOptions extends InitOptions {
locales: string[];
}
const useInformationTableItems = (newVersionAvailable?: string): InformationTableItem[] => {
const { attributes } = usePackageAttributesStore();
const { primaryColor } = useColorTheme();
const { t } = useTranslation(['layout/modals/about']);
const { configVersion } = useConfigContext();
const { configs } = useConfigStore();
let items: InformationTableItem[] = [];
if (i18n?.reportNamespaces) {
const usedI18nNamespaces = i18n.reportNamespaces.getUsedNamespaces();
const initOptions = i18n.options as ExtendedInitOptions;
items = [
...items,
{
icon: <IconLanguage size={20} />,
label: 'i18n',
content: (
<Badge variant="light" color={primaryColor}>
{usedI18nNamespaces.length}
</Badge>
),
},
{
icon: <IconVocabulary size={20} />,
label: 'locales',
content: (
<Badge variant="light" color={primaryColor}>
{initOptions.locales.length}
</Badge>
),
},
];
}
items = [
{
icon: <IconSchema size={20} />,
label: 'configurationSchemaVersion',
content: (
<Badge variant="light" color={primaryColor}>
{configVersion}
</Badge>
),
},
{
icon: <IconFile size={20} />,
label: 'configurationsCount',
content: (
<Badge variant="light" color={primaryColor}>
{configs.length}
</Badge>
),
},
{
icon: <IconVersions size={20} />,
label: 'version',
content: (
<Group position="right">
<Badge variant="light" color={primaryColor}>
{attributes.packageVersion ?? 'Unknown'}
</Badge>
{newVersionAvailable && (
<HoverCard shadow="md" position="top" withArrow>
<HoverCard.Target>
<motion.div
initial={{ scale: 1.2 }}
animate={{
scale: [0.8, 1.1, 1],
rotate: [0, 10, 0],
}}
transition={{ duration: 0.8, ease: 'easeInOut' }}
>
<Badge color="green" variant="filled">
{t('version.new', { newVersion: newVersionAvailable })}
</Badge>
</motion.div>
</HoverCard.Target>
<HoverCard.Dropdown>
<Text>
{
t('version.dropdown', { currentVersion: attributes.packageVersion }).split(
'{{newVersion}}'
)[0]
}
<b>
<Anchor
target="_blank"
href={`https://github.com/ajnart/homarr/releases/tag/${newVersionAvailable}`}
>
{newVersionAvailable}
</Anchor>
</b>
{
t('version.dropdown', { currentVersion: attributes.packageVersion }).split(
'{{newVersion}}'
)[1]
}
</Text>
</HoverCard.Dropdown>
</HoverCard>
)}
</Group>
),
},
{
icon: <IconAnchor size={20} />,
label: 'nodeEnvironment',
content: (
<Badge variant="light" color={primaryColor}>
{attributes.environment}
</Badge>
),
},
...items,
];
return items;
};
const useStyles = createStyles(() => ({
informationTableColumn: {
textAlign: 'right',
},
informationIcon: {
cursor: 'default',
},
}));

View File

@@ -0,0 +1,116 @@
import {
Anchor,
Avatar,
Group,
Pagination,
Stack,
Table,
Text,
Title,
} from '@mantine/core';
import { usePagination } from '@mantine/hooks';
import { Trans, useTranslation } from 'next-i18next';
// Generated by https://quicktype.io
export interface Contributors {
login: string;
id: number;
node_id: string;
avatar_url: string;
gravatar_id: string;
url: string;
html_url: string;
followers_url: string;
following_url: string;
gists_url: string;
starred_url: string;
subscriptions_url: string;
organizations_url: string;
repos_url: string;
events_url: string;
received_events_url: string;
type: Type;
site_admin: boolean;
contributions: number;
}
export enum Type {
Bot = 'Bot',
User = 'User',
}
const PAGINATION_ITEMS = 20;
export function ContributorsTable({ contributors }: { contributors: Contributors[] }) {
const pagination = usePagination({
total: contributors.length / PAGINATION_ITEMS,
initialPage: 1,
});
const { t } = useTranslation(['layout/modals/about']);
const rows = contributors
.slice(
(pagination.active - 1) * PAGINATION_ITEMS,
(pagination.active - 1) * PAGINATION_ITEMS + PAGINATION_ITEMS
)
.map((contributor) => (
<tr key={contributor.id}>
<td>
<Anchor href={`https://github.com/${contributor.login}`} target="_blank">
<Group noWrap>
<Avatar size={25} radius="lg" src={contributor.avatar_url} alt={contributor.login} />
{contributor.login}
</Group>
</Anchor>
</td>
<td>{contributor.contributions}</td>
</tr>
));
return (
<Stack>
<Title order={3}>{t('contributors', { count: contributors.length })}</Title>
<Text>
<Trans
i18nKey="layout/modals/about:contributorsDescription"
components={{
a: <Anchor href="https://homarr.dev/docs/community/developer-guides" target="_blank" />,
}}
/>
</Text>
<Table withBorder>
<thead>
<tr>
<th
style={{
width: 400,
}}
>
Contributor
</th>
<th
style={{
width: 400,
}}
>
Contributions
</th>
</tr>
</thead>
<tbody>{rows}</tbody>
</Table>
<Pagination
style={{
justifyContent: 'center',
}}
total={contributors.length / PAGINATION_ITEMS}
value={pagination.active}
onNextPage={() => pagination.next()}
onPreviousPage={() => pagination.previous()}
onChange={(targetPage) => pagination.setPage(targetPage)}
/>
</Stack>
);
}

View File

@@ -1,5 +1,6 @@
import { Anchor, Box, Collapse, Flex, Table, Text } from '@mantine/core';
import { Anchor, Box, Button, Collapse, Container, Flex, Stack, Table, Text } from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import { modals } from '@mantine/modals';
import { useTranslation } from 'next-i18next';
import { usePackageAttributesStore } from '~/tools/client/zustands/usePackageAttributesStore';
@@ -7,7 +8,8 @@ export default function Credits() {
const { t } = useTranslation('settings/common');
return (
<Flex justify="center" direction="column" mt="xs">
<Stack>
<DependencyTable />
<Text
style={{
fontSize: '0.90rem',
@@ -24,49 +26,47 @@ export default function Credits() {
</Anchor>{' '}
and you!
</Text>
<DependencyTable />
</Flex>
</Stack>
);
}
const DependencyTable = () => {
const { t } = useTranslation('settings/common');
const [opened, { toggle }] = useDisclosure(false);
const { attributes } = usePackageAttributesStore();
return (
<>
<Text variant="link" mx="auto" size="xs" opacity={0.3} onClick={toggle}>
{t('credits.thirdPartyContent')}
</Text>
<Collapse in={opened}>
<Box
sx={(theme) => ({
backgroundColor:
theme.colorScheme === 'dark' ? theme.colors.dark[6] : theme.colors.gray[0],
padding: theme.spacing.xl,
borderRadius: theme.radius.md,
})}
mt="md"
>
<Table>
<thead>
<tr>
<th>{t('credits.thirdPartyContentTable.dependencyName')}</th>
<th>{t('credits.thirdPartyContentTable.dependencyVersion')}</th>
</tr>
</thead>
{Object.keys(attributes.dependencies).map((key, index) => (
<tbody key={`dependency-${index}`}>
<Button
style={{
justifyContent: 'start',
}}
variant="light"
mx="auto"
size="xs"
onClick={() =>
modals.open({
title: t('credits.thirdPartyContent'),
size: 'xl',
children: (
<Table>
<thead>
<tr>
<td>{key}</td>
<td>{attributes.dependencies[key]}</td>
<th>{t('credits.thirdPartyContentTable.dependencyName')}</th>
<th>{t('credits.thirdPartyContentTable.dependencyVersion')}</th>
</tr>
</thead>
<tbody>
{Object.keys(attributes.dependencies).map((key, index) => (
<tr>
<td>{key}</td>
<td>{attributes.dependencies[key]}</td>
</tr>
))}
</tbody>
))}
</Table>
</Box>
</Collapse>
</>
</Table>
),
})
}
>
{t('credits.thirdPartyContent')}
</Button>
);
};

View File

@@ -0,0 +1,123 @@
import {
Anchor,
Avatar,
Group,
Pagination,
Stack,
Table,
Text,
Title,
} from '@mantine/core';
import { usePagination } from '@mantine/hooks';
import { Trans, useTranslation } from 'next-i18next';
import CrowdinReport from '../../../../../data/crowdin-report.json';
const PAGINATION_ITEMS = 20;
export function TranslatorsTable({ loadedLanguages }: { loadedLanguages: number }) {
const { t } = useTranslation(['layout/modals/about']);
const translators = CrowdinReport.data;
const pagination = usePagination({
total: translators.length / PAGINATION_ITEMS,
initialPage: 1,
});
const rows = translators
.slice(
(pagination.active - 1) * PAGINATION_ITEMS,
(pagination.active - 1) * PAGINATION_ITEMS + PAGINATION_ITEMS
)
.map((translator) => (
<tr key={translator.user.id}>
<td
style={{
width: 400,
}}
>
<Anchor href={`https://crowdin.com/profile/${translator.user.username}`} target="_blank">
<Group noWrap>
<Avatar
size={25}
radius="lg"
src={translator.user.avatarUrl}
alt={translator.user.username}
/>
{translator.user.fullName}
</Group>
</Anchor>
</td>
<td
style={{
width: 400,
}}
>
{translator.translated}
</td>
<td
style={{
width: 400,
}}
>
{translator.approved}
</td>
<td
style={{
width: 400,
}}
>
{translator.target}
</td>
<td
style={{
width: 400,
}}
>
<Text lineClamp={1}>
{translator.languages.map((language) => (
<span key={language.id}>{language.name}, </span>
))}
</Text>
</td>
</tr>
));
return (
<Stack>
<Title order={3}>{t('translators', { count: translators.length })}</Title>
<Text>
<Trans
i18nKey="layout/modals/about:translatorsDescription"
values={{
languages: loadedLanguages,
}}
components={{
a: <Anchor href="https://homarr.dev/docs/community/translations" target="_blank" />,
}}
/>
</Text>
<Table withBorder>
<thead>
<tr>
<th>Name</th>
<th>Translated</th>
<th>Approved</th>
<th>Target</th>
<th>Languages</th>
</tr>
</thead>
<tbody>{rows}</tbody>
</Table>
<Pagination
style={{
justifyContent: 'center',
}}
total={translators.length / PAGINATION_ITEMS}
value={pagination.active}
onNextPage={() => pagination.next()}
onPreviousPage={() => pagination.previous()}
onChange={(targetPage) => pagination.setPage(targetPage)}
/>
</Stack>
);
}

View File

@@ -1,34 +1,26 @@
import { Avatar, Badge, Indicator, Menu, UnstyledButton, useMantineTheme } from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import { Avatar, Menu, UnstyledButton, useMantineTheme } from '@mantine/core';
import {
IconDashboard,
IconHomeShare,
IconInfoCircle,
IconLogin,
IconLogout,
IconMoonStars,
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 { useTranslation } from 'next-i18next';
import Link from 'next/link';
import { forwardRef } from 'react';
import { AboutModal } from '~/components/layout/header/About/AboutModal';
import { useColorScheme } from '~/hooks/use-colorscheme';
import { usePackageAttributesStore } from '~/tools/client/zustands/usePackageAttributesStore';
import { REPO_URL } from '../../../../data/constants';
import { useBoardLink } from '../Templates/BoardLayout';
export const AvatarMenu = () => {
const { t } = useTranslation('layout/header');
const [aboutModalOpened, aboutModal] = useDisclosure(false);
const { data: sessionData } = useSession();
const { colorScheme, toggleColorScheme } = useColorScheme();
const newVersionAvailable = useNewVersionAvailable();
const Icon = colorScheme === 'dark' ? IconSun : IconMoonStars;
const defaultBoardHref = useBoardLink('/board');
@@ -38,10 +30,7 @@ export const AvatarMenu = () => {
<UnstyledButton>
<Menu width={256}>
<Menu.Target>
<CurrentUserAvatar
newVersionAvailable={newVersionAvailable ? true : false}
user={sessionData?.user ?? null}
/>
<CurrentUserAvatar user={sessionData?.user ?? null} />
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
@@ -74,19 +63,6 @@ export const AvatarMenu = () => {
<Menu.Divider />
</>
)}
<Menu.Item
icon={<IconInfoCircle size="1rem" />}
rightSection={
newVersionAvailable && (
<Badge variant="light" color="blue">
{t('actions.avatar.about.new')}
</Badge>
)
}
onClick={() => aboutModal.open()}
>
{t('actions.avatar.about.label')}
</Menu.Item>
{sessionData?.user ? (
<Menu.Item
icon={<IconLogout size="1rem" />}
@@ -109,35 +85,18 @@ export const AvatarMenu = () => {
</Menu.Dropdown>
</Menu>
</UnstyledButton>
<AboutModal
opened={aboutModalOpened}
closeModal={aboutModal.close}
newVersionAvailable={newVersionAvailable}
/>
</>
);
};
type CurrentUserAvatarProps = {
newVersionAvailable: boolean;
user: User | null;
};
const CurrentUserAvatar = forwardRef<HTMLDivElement, CurrentUserAvatarProps>(
({ user, newVersionAvailable, ...others }, ref) => {
({ user, ...others }, ref) => {
const { primaryColor } = useMantineTheme();
if (!user) return <Avatar ref={ref} {...others} />;
if (newVersionAvailable)
return (
<Indicator withBorder offset={2} color="blue" processing size={15}>
<Avatar ref={ref} color={primaryColor} {...others}>
{user.name?.slice(0, 2).toUpperCase()}
</Avatar>
</Indicator>
);
return (
<Avatar ref={ref} color={primaryColor} {...others}>
{user.name?.slice(0, 2).toUpperCase()}
@@ -145,15 +104,3 @@ 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;
};

280
src/pages/manage/about.tsx Normal file
View File

@@ -0,0 +1,280 @@
import {
Accordion,
ActionIcon,
Anchor,
Badge,
Divider,
Group,
HoverCard,
Kbd,
Stack,
Table,
Text,
createStyles
} from '@mantine/core';
import {
IconAnchor,
IconKey,
IconLanguage,
IconSchema,
IconVersions,
IconVocabulary
} from '@tabler/icons-react';
import { useQuery } from '@tanstack/react-query';
import { InitOptions } from 'i18next';
import { GetServerSidePropsContext } from 'next';
import { Trans, i18n, useTranslation } from 'next-i18next';
import Head from 'next/head';
import { ReactNode } from 'react';
import { ManageLayout } from '~/components/layout/Templates/ManageLayout';
import { Contributors, ContributorsTable } from '~/components/layout/header/About/Contributors';
import Credits from '~/components/layout/header/About/Credits';
import Tip from '~/components/layout/header/About/Tip';
import { TranslatorsTable } from '~/components/layout/header/About/Translators';
import { useConfigContext } from '~/config/provider';
import { usePackageAttributesStore } from '~/tools/client/zustands/usePackageAttributesStore';
import { useColorTheme } from '~/tools/color';
import { getServerSideTranslations } from '~/tools/server/getServerSideTranslations';
import { REPO_URL } from '../../../data/constants';
interface InformationTableItem {
icon: ReactNode;
label: string;
content: ReactNode;
}
interface ExtendedInitOptions extends InitOptions {
locales: string[];
}
const useInformationTableItems = (newVersionAvailable?: string): InformationTableItem[] => {
const { attributes } = usePackageAttributesStore();
const { primaryColor } = useColorTheme();
const { t } = useTranslation(['layout/modals/about']);
const { configVersion } = useConfigContext();
let items: InformationTableItem[] = [];
if (i18n?.reportNamespaces) {
const usedI18nNamespaces = i18n.reportNamespaces.getUsedNamespaces();
const initOptions = i18n.options as ExtendedInitOptions;
items = [
...items,
{
icon: <IconLanguage size={20} />,
label: 'i18n',
content: (
<Badge variant="light" color={primaryColor}>
{usedI18nNamespaces.length}
</Badge>
),
},
{
icon: <IconVocabulary size={20} />,
label: 'locales',
content: (
<Badge variant="light" color={primaryColor}>
{initOptions.locales.length}
</Badge>
),
},
];
}
items = [
{
icon: <IconSchema size={20} />,
label: 'configurationSchemaVersion',
content: (
<Badge variant="light" color={primaryColor}>
{configVersion}
</Badge>
),
},
{
icon: <IconVersions size={20} />,
label: 'version',
content: (
<Group position="right">
<Badge variant="light" color={primaryColor}>
{attributes.packageVersion ?? 'Unknown'}
</Badge>
{newVersionAvailable && (
<HoverCard shadow="md" position="top" withArrow>
<HoverCard.Target>
<Badge color="teal" variant="light">
{t('version.new', { newVersion: newVersionAvailable })}
</Badge>
</HoverCard.Target>
<HoverCard.Dropdown>
<Text>
{
t('version.dropdown', { currentVersion: attributes.packageVersion }).split(
'{{newVersion}}'
)[0]
}
<b>
<Anchor
target="_blank"
href={`https://github.com/ajnart/homarr/releases/tag/${newVersionAvailable}`}
>
{newVersionAvailable}
</Anchor>
</b>
{
t('version.dropdown', { currentVersion: attributes.packageVersion }).split(
'{{newVersion}}'
)[1]
}
</Text>
</HoverCard.Dropdown>
</HoverCard>
)}
</Group>
),
},
{
icon: <IconAnchor size={20} />,
label: 'nodeEnvironment',
content: (
<Badge variant="light" color={primaryColor}>
{attributes.environment}
</Badge>
),
},
...items,
];
return items;
};
const useStyles = createStyles(() => ({
informationTableColumn: {
textAlign: 'right',
},
informationIcon: {
cursor: 'default',
},
}));
export const Page = ({ contributors }: { contributors: Contributors[] }) => {
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`, {
cache: 'force-cache',
}).then((res) => res.json()),
});
const { attributes } = usePackageAttributesStore();
if (!i18n) {
return;
}
const initOptions = i18n.options as ExtendedInitOptions;
const newVersionAvailable =
data?.tag_name > `v${attributes.packageVersion}` ? data?.tag_name : undefined;
const informations = useInformationTableItems(newVersionAvailable);
const { t } = useTranslation(['layout/modals/about']);
const { classes } = useStyles();
const keybinds = [
{ key: 'Mod + J', shortcut: t('layout/modals/about:actions.toggleTheme') },
{ key: 'Mod + K', shortcut: t('layout/modals/about:actions.focusSearchBar') },
{ key: 'Mod + B', shortcut: t('layout/modals/about:actions.openDocker') },
{ key: 'Mod + E', shortcut: t('layout/modals/about:actions.toggleEdit') },
];
const rows = keybinds.map((element) => (
<tr key={element.key}>
<td>
<Kbd>{element.key}</Kbd>
</td>
<td>
<Text>{element.shortcut}</Text>
</td>
</tr>
));
return (
<ManageLayout>
<Head>
<title>About Homarr</title>
</Head>
<Stack>
<Text>
<Trans i18nKey="layout/modals/about:description" />
</Text>
<Table withBorder>
<tbody>
{informations.map((item, index) => (
<tr key={index}>
<td>
<Group spacing="xs">
<ActionIcon className={classes.informationIcon} variant="default">
{item.icon}
</ActionIcon>
<Text>
<Trans
i18nKey={`layout/modals/about:metrics.${item.label}`}
components={{ b: <b /> }}
/>
</Text>
</Group>
</td>
<td className={classes.informationTableColumn} style={{ maxWidth: 200 }}>
{item.content}
</td>
</tr>
))}
</tbody>
</Table>
<Accordion mb={5} variant="contained" radius="md">
<Accordion.Item value="keybinds">
<Accordion.Control icon={<IconKey size={20} />}>
{t('layout/modals/about:keybinds')}
</Accordion.Control>
<Accordion.Panel>
<Table mb={5}>
<thead>
<tr>
<th>{t('layout/modals/about:key')}</th>
<th>{t('layout/modals/about:action')}</th>
</tr>
</thead>
<tbody>{rows}</tbody>
</Table>
<Tip>{t('layout/modals/about:tip')}</Tip>
</Accordion.Panel>
</Accordion.Item>
</Accordion>
<TranslatorsTable loadedLanguages={initOptions.locales.length} />
<Divider />
<ContributorsTable contributors={contributors} />
<Credits />
</Stack>
</ManageLayout>
);
};
export async function getServerSideProps({ locale }: GetServerSidePropsContext) {
const contributors = (await fetch(
`https://api.github.com/repos/${REPO_URL}/contributors?per_page=100`,
{
cache: 'force-cache',
}
).then((res) => res.json())) as Contributors[];
return {
props: {
contributors,
...(await getServerSideTranslations(['layout/manage', 'manage/index'], locale)),
},
};
}
export default Page;

View File

@@ -1,5 +1,15 @@
import bcrypt from 'bcryptjs';
import { ReactNode } from 'react';
export const hashPassword = (password: string, salt: string) => {
return bcrypt.hashSync(password, salt);
};
interface ConditionalWrapperProps {
condition: boolean;
wrapper: (children: ReactNode) => JSX.Element;
children: ReactNode;
}
export const ConditionalWrapper: React.FC<ConditionalWrapperProps> = ({ condition, wrapper, children }) =>
condition ? wrapper(children) : children;