Merge pull request #1539 from ajnart/about-page
Turn about modal into a static page
This commit is contained in:
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
}));
|
||||
116
src/components/layout/header/About/Contributors.tsx
Normal file
116
src/components/layout/header/About/Contributors.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
123
src/components/layout/header/About/Translators.tsx
Normal file
123
src/components/layout/header/About/Translators.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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
280
src/pages/manage/about.tsx
Normal 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;
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user