Completely rework the about page

This commit is contained in:
ajnart
2023-11-01 02:53:39 +01:00
parent 6f12863863
commit 58e57492e2
12 changed files with 513 additions and 812 deletions

View File

@@ -17,10 +17,6 @@
"preferences": "User preferences",
"defaultBoard": "Default dashboard",
"manage": "Manage",
"about": {
"label": "About",
"new": "New"
},
"logout": "Logout from {{username}}",
"login": "Login"
}

View File

@@ -27,6 +27,9 @@
"items": {
"docker": "Docker"
}
},
"about": {
"title": "About"
}
}
}

View File

@@ -1,12 +1,14 @@
{
"description": "Homarr is a <strong>sleek</strong>, <strong>modern</strong> dashboard that puts all of your apps and services at your fingertips. With Homarr, you can access and control everything in one convenient location. Homarr seamlessly integrates with the apps you've added, providing you with valuable information and giving you complete control. Installation is a breeze, and Homarr supports a wide range of deployment methods.",
"contact": "Having trouble or questions? Connect with us!",
"addToDashboard": "Add to Dashboard",
"tip": "Mod refers to your modifier key, it is Ctrl and Command/Super/Windows key",
"key": "Shortcut key",
"action": "Action",
"keybinds": "Keybinds",
"documentation": "Documentation",
"translators": "Translators ({{count}})",
"translatorsDescription": "Thanks to these people, Homarr is available in {{languages}} languages! Want to help translate Homarr into your language? Read how to do so <a>here</a>.",
"contributors": "Contributors ({{count}})",
"contributorsDescription": "These people have built the code that makes homarr work! Want to help build Homarr? Read how to do so <a>here</a>",
"actions": {
"toggleTheme": "Toggle light/dark mode",
"focusSearchBar": "Focus on search bar",
@@ -15,7 +17,6 @@
},
"metrics": {
"configurationSchemaVersion": "Configuration schema version",
"configurationsCount": "Available configurations",
"version": "Version",
"nodeEnvironment": "Node environment",
"i18n": "Loaded I18n translation namespaces",

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,325 +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';
import { TranslatorsTable } from './Translators';
import { ContributorsTable } from './Contributors';
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 />
<TranslatorsTable />
<ContributorsTable />
</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

@@ -2,16 +2,14 @@ import {
Anchor,
Avatar,
Group,
Loader,
ScrollArea,
Pagination,
Stack,
Table,
Text,
createStyles,
Title,
} from '@mantine/core';
import { useQuery } from '@tanstack/react-query';
import { cache } from 'react';
import { REPO_URL } from '../../../../../data/constants';
import { usePagination } from '@mantine/hooks';
import { Trans, useTranslation } from 'next-i18next';
// Generated by https://quicktype.io
@@ -42,53 +40,77 @@ export enum Type {
User = 'User',
}
export function ContributorsTable() {
// Type data as Contributors
const { data, isFetching } = useQuery({
queryKey: ['contributors'],
cacheTime: 1000 * 60 * 60 * 24,
staleTime: 1000 * 60 * 60 * 5,
queryFn: () =>
fetch(`https://api.github.com/repos/${REPO_URL}/contributors?per_page=25`, {
cache: 'force-cache',
}).then((res) => res.json()) as Promise<Contributors[]>,
});
if (isFetching || !data) return <Loader />;
const PAGINATION_ITEMS = 20;
const rows = data.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>
));
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>
<h5>Credits to our amazing contributors</h5>
<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>
<ScrollArea h={800}>
<Table miw={700}>
<thead>
<tr>
<th>Contributor</th>
<th>Contributions</th>
</tr>
</thead>
<tbody>{rows}</tbody>
</Table>
</ScrollArea>
<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,48 @@ 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"
opacity={0.3}
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

@@ -1,20 +0,0 @@
.header {
position: sticky;
top: 0;
background-color: var(--mantine-color-body);
transition: box-shadow 150ms ease;
&::after {
content: '';
position: absolute;
left: 0;
right: 0;
bottom: 0;
border-bottom: rem(1px) solid
light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-3));
}
}
.scrolled {
box-shadow: var(--mantine-shadow-sm);
}

View File

@@ -1,60 +1,123 @@
import { Anchor, Avatar, Group, ScrollArea, Stack, Table, Text, createStyles } from '@mantine/core';
import cx from 'clsx';
import Link from 'next/link';
import { useState } from 'react';
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';
export function TranslatorsTable() {
// Only the first 30 translators are shown
const translators = CrowdinReport.data.slice(0, 30);
const PAGINATION_ITEMS = 20;
const rows = translators.map((translator) => (
<tr key={translator.user.id}>
<td>
<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.username}
</Group>
</Anchor>
</td>
<td>{translator.translated}</td>
<td>{translator.approved}</td>
<td>{translator.target}</td>
<td>
<Text lineClamp={1}>
{translator.languages.map((language) => (
<span key={language.id}>{language.name}, </span>
))}
</Text>
</td>
</tr>
));
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>
<h5>Credits to our amazing translators</h5>
<ScrollArea h={800}>
<Table miw={700}>
<thead>
<tr>
<th>Translator</th>
<th>Translated</th>
<th>Approved</th>
<th>Target</th>
<th>Languages</th>
</tr>
</thead>
<tbody>{rows}</tbody>
</Table>
</ScrollArea>
<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;
};

View File

@@ -4,6 +4,7 @@ import {
Anchor,
Badge,
Button,
Divider,
Grid,
Group,
HoverCard,
@@ -28,12 +29,14 @@ import {
IconVocabulary,
IconWorldWww,
} from '@tabler/icons-react';
import { useQuery } from '@tanstack/react-query';
import { motion } from 'framer-motion';
import { InitOptions } from 'i18next';
import { GetStaticPropsContext } from 'next';
import { Trans, i18n, useTranslation } from 'next-i18next';
import { ReactNode } from 'react';
import { ContributorsTable } from '~/components/layout/header/About/Contributors';
import { ReactElement, 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';
@@ -44,6 +47,8 @@ import { useColorTheme } from '~/tools/color';
import { queryClient } from '~/tools/server/configurations/tanstack/queryClient.tool';
import { getServerSideTranslations } from '~/tools/server/getServerSideTranslations';
import { REPO_URL } from '../../../data/constants';
interface InformationTableItem {
icon: ReactNode;
label: string;
@@ -60,7 +65,6 @@ const useInformationTableItems = (newVersionAvailable?: string): InformationTabl
const { t } = useTranslation(['layout/modals/about']);
const { configVersion } = useConfigContext();
const { configs } = useConfigStore();
let items: InformationTableItem[] = [];
@@ -101,15 +105,6 @@ const useInformationTableItems = (newVersionAvailable?: string): InformationTabl
</Badge>
),
},
{
icon: <IconFile size={20} />,
label: 'configurationsCount',
content: (
<Badge variant="light" color={primaryColor}>
{configs.length}
</Badge>
),
},
{
icon: <IconVersions size={20} />,
label: 'version',
@@ -121,18 +116,9 @@ const useInformationTableItems = (newVersionAvailable?: string): InformationTabl
{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>
<Badge color="teal" variant="light">
{t('version.new', { newVersion: newVersionAvailable })}
</Badge>
</HoverCard.Target>
<HoverCard.Dropdown>
<Text>
@@ -185,10 +171,26 @@ const useStyles = createStyles(() => ({
},
}));
const AboutPage = () => {
const newVersionAvailable = queryClient.getQueryData<string>(['github/latest']);
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 { t } = useTranslation(['layout/modals/about']);
const { classes } = useStyles();
const keybinds = [
@@ -209,109 +211,78 @@ const AboutPage = () => {
));
return (
<Stack>
<Text mb="lg">
<Trans i18nKey="layout/modals/about:description" />
</Text>
<ManageLayout>
<Stack>
<Text>
<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>
<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>
<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 />
<TranslatorsTable />
<ContributorsTable />
</Stack>
<TranslatorsTable loadedLanguages={initOptions.locales.length} />
<Divider />
<ContributorsTable contributors={contributors} />
<Credits />
</Stack>
</ManageLayout>
);
};
async function getStaticProps({ locale }: GetStaticPropsContext) {
export async function getStaticProps({ locale }: GetStaticPropsContext) {
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 {
...(await getServerSideTranslations(['authentication/login'], locale)),
};
props: {
contributors,
...(await getServerSideTranslations(['layout/manage', 'manage/index'], locale)),
},
};
}
export default AboutPage;
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;