mirror of
https://github.com/NginxProxyManager/nginx-proxy-manager.git
synced 2025-07-17 06:54:34 +00:00
Moved v3 code from NginxProxyManager/nginx-proxy-manager-3 to NginxProxyManager/nginx-proxy-manager
This commit is contained in:
23
frontend/src/components/EmptyList.tsx
Normal file
23
frontend/src/components/EmptyList.tsx
Normal file
@ -0,0 +1,23 @@
|
||||
import { ReactNode } from "react";
|
||||
|
||||
import { Box, Heading, Text } from "@chakra-ui/react";
|
||||
|
||||
interface EmptyListProps {
|
||||
title: string;
|
||||
summary: string;
|
||||
createButton?: ReactNode;
|
||||
}
|
||||
|
||||
function EmptyList({ title, summary, createButton }: EmptyListProps) {
|
||||
return (
|
||||
<Box textAlign="center" py={10} px={6}>
|
||||
<Heading as="h4" size="md" mt={6} mb={2}>
|
||||
{title}
|
||||
</Heading>
|
||||
<Text color="gray.500">{summary}</Text>
|
||||
{createButton}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export { EmptyList };
|
32
frontend/src/components/Flag/Flag.tsx
Normal file
32
frontend/src/components/Flag/Flag.tsx
Normal file
@ -0,0 +1,32 @@
|
||||
import { Box } from "@chakra-ui/layout";
|
||||
import { hasFlag } from "country-flag-icons";
|
||||
// @ts-ignore Creating a typing for a subfolder is not easily possible
|
||||
import Flags from "country-flag-icons/react/3x2";
|
||||
|
||||
interface FlagProps {
|
||||
/**
|
||||
* Additional Class
|
||||
*/
|
||||
className?: string;
|
||||
/**
|
||||
* Two letter country code of flag
|
||||
*/
|
||||
countryCode: string;
|
||||
}
|
||||
function Flag({ className, countryCode }: FlagProps) {
|
||||
countryCode = countryCode.toUpperCase();
|
||||
|
||||
if (hasFlag(countryCode)) {
|
||||
// @ts-ignore have to do this because of above
|
||||
const FlagElement = Flags[countryCode] as any;
|
||||
return (
|
||||
<Box as={FlagElement} title={countryCode} className={className} w={6} />
|
||||
);
|
||||
} else {
|
||||
console.error(`No flag for country ${countryCode} found!`);
|
||||
|
||||
return <Box w={6} h={4} />;
|
||||
}
|
||||
}
|
||||
|
||||
export { Flag };
|
1
frontend/src/components/Flag/index.ts
Normal file
1
frontend/src/components/Flag/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from "./Flag";
|
64
frontend/src/components/Footer.tsx
Normal file
64
frontend/src/components/Footer.tsx
Normal file
@ -0,0 +1,64 @@
|
||||
import {
|
||||
Box,
|
||||
Container,
|
||||
Link,
|
||||
Stack,
|
||||
Text,
|
||||
Tooltip,
|
||||
useColorModeValue,
|
||||
} from "@chakra-ui/react";
|
||||
import { intl } from "locale";
|
||||
|
||||
function Footer() {
|
||||
return (
|
||||
<Box
|
||||
bg={useColorModeValue("gray.50", "gray.900")}
|
||||
color={useColorModeValue("gray.700", "gray.200")}>
|
||||
<Container
|
||||
as={Stack}
|
||||
maxW="6xl"
|
||||
py={4}
|
||||
direction={{ base: "column", md: "row" }}
|
||||
spacing={4}
|
||||
justify={{ base: "center", md: "space-between" }}
|
||||
align={{ base: "center", md: "center" }}>
|
||||
<Text>
|
||||
{intl.formatMessage(
|
||||
{ id: "footer.copyright" },
|
||||
{ year: new Date().getFullYear() },
|
||||
)}
|
||||
</Text>
|
||||
<Stack direction="row" spacing={6}>
|
||||
<Link
|
||||
href="https://nginxproxymanager.com?utm_source=npm"
|
||||
isExternal
|
||||
rel="noopener noreferrer">
|
||||
{intl.formatMessage({ id: "footer.userguide" })}
|
||||
</Link>
|
||||
<Link
|
||||
href="https://github.com/NginxProxyManager/nginx-proxy-manager/releases?utm_source=npm"
|
||||
isExternal
|
||||
rel="noopener noreferrer">
|
||||
{intl.formatMessage({ id: "footer.changelog" })}
|
||||
</Link>
|
||||
<Link
|
||||
href="https://github.com/NginxProxyManager/nginx-proxy-manager?utm_source=npm"
|
||||
isExternal
|
||||
rel="noopener noreferrer">
|
||||
{intl.formatMessage({ id: "footer.github" })}
|
||||
</Link>
|
||||
<Tooltip label={process.env.REACT_APP_COMMIT}>
|
||||
<Link
|
||||
href="https://github.com/NginxProxyManager/nginx-proxy-manager/releases?utm_source=npm"
|
||||
isExternal
|
||||
rel="noopener noreferrer">
|
||||
v{process.env.REACT_APP_VERSION}
|
||||
</Link>
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
</Container>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export { Footer };
|
56
frontend/src/components/HelpDrawer/HelpDrawer.tsx
Normal file
56
frontend/src/components/HelpDrawer/HelpDrawer.tsx
Normal file
@ -0,0 +1,56 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import {
|
||||
Button,
|
||||
Drawer,
|
||||
DrawerContent,
|
||||
DrawerOverlay,
|
||||
DrawerBody,
|
||||
useDisclosure,
|
||||
} from "@chakra-ui/react";
|
||||
import { getLocale } from "locale";
|
||||
import { FiHelpCircle } from "react-icons/fi";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
|
||||
import { getHelpFile } from "../../locale/src/HelpDoc";
|
||||
|
||||
interface HelpDrawerProps {
|
||||
/**
|
||||
* Section to show
|
||||
*/
|
||||
section: string;
|
||||
}
|
||||
function HelpDrawer({ section }: HelpDrawerProps) {
|
||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||
const [markdownText, setMarkdownText] = useState("");
|
||||
const lang = getLocale(true);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const docFile = getHelpFile(lang, section) as any;
|
||||
fetch(docFile)
|
||||
.then((response) => response.text())
|
||||
.then(setMarkdownText);
|
||||
} catch (ex: any) {
|
||||
setMarkdownText(`**ERROR:** ${ex.message}`);
|
||||
}
|
||||
}, [lang, section]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button size="sm" onClick={onOpen}>
|
||||
<FiHelpCircle />
|
||||
</Button>
|
||||
<Drawer onClose={onClose} isOpen={isOpen} size="xl">
|
||||
<DrawerOverlay />
|
||||
<DrawerContent>
|
||||
<DrawerBody className="helpdoc-body">
|
||||
<ReactMarkdown>{markdownText}</ReactMarkdown>
|
||||
</DrawerBody>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export { HelpDrawer };
|
1
frontend/src/components/HelpDrawer/index.ts
Normal file
1
frontend/src/components/HelpDrawer/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from "./HelpDrawer";
|
19
frontend/src/components/Loader/Loader.tsx
Normal file
19
frontend/src/components/Loader/Loader.tsx
Normal file
@ -0,0 +1,19 @@
|
||||
import { ReactNode } from "react";
|
||||
|
||||
import cn from "classnames";
|
||||
|
||||
interface LoaderProps {
|
||||
/**
|
||||
* Child elements within
|
||||
*/
|
||||
children?: ReactNode;
|
||||
/**
|
||||
* Additional Class
|
||||
*/
|
||||
className?: string;
|
||||
}
|
||||
function Loader({ children, className }: LoaderProps) {
|
||||
return <div className={cn({ loader: true }, className)}>{children}</div>;
|
||||
}
|
||||
|
||||
export { Loader };
|
1
frontend/src/components/Loader/index.ts
Normal file
1
frontend/src/components/Loader/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from "./Loader";
|
12
frontend/src/components/Loading.tsx
Normal file
12
frontend/src/components/Loading.tsx
Normal file
@ -0,0 +1,12 @@
|
||||
import { Box } from "@chakra-ui/react";
|
||||
import { Loader } from "components";
|
||||
|
||||
function Loading() {
|
||||
return (
|
||||
<Box textAlign="center">
|
||||
<Loader />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export { Loading };
|
62
frontend/src/components/LocalePicker.tsx
Normal file
62
frontend/src/components/LocalePicker.tsx
Normal file
@ -0,0 +1,62 @@
|
||||
import {
|
||||
Button,
|
||||
Box,
|
||||
Menu,
|
||||
MenuButton,
|
||||
MenuList,
|
||||
MenuItem,
|
||||
} from "@chakra-ui/react";
|
||||
import { Flag } from "components";
|
||||
import { useLocaleState } from "context";
|
||||
import {
|
||||
changeLocale,
|
||||
getFlagCodeForLocale,
|
||||
intl,
|
||||
localeOptions,
|
||||
} from "locale";
|
||||
|
||||
interface LocalPickerProps {
|
||||
/**
|
||||
* On change handler
|
||||
*/
|
||||
onChange?: any;
|
||||
/**
|
||||
* Class
|
||||
*/
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function LocalePicker({ onChange, className }: LocalPickerProps) {
|
||||
const { locale, setLocale } = useLocaleState();
|
||||
|
||||
const changeTo = (lang: string) => {
|
||||
changeLocale(lang);
|
||||
setLocale(lang);
|
||||
onChange && onChange(locale);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box className={className}>
|
||||
<Menu>
|
||||
<MenuButton as={Button}>
|
||||
<Flag countryCode={getFlagCodeForLocale(locale)} />
|
||||
</MenuButton>
|
||||
<MenuList>
|
||||
{localeOptions.map((item) => {
|
||||
return (
|
||||
<MenuItem
|
||||
icon={<Flag countryCode={getFlagCodeForLocale(item[0])} />}
|
||||
onClick={() => changeTo(item[0])}
|
||||
// rel={item[1]}
|
||||
key={`locale-${item[0]}`}>
|
||||
<span>{intl.formatMessage({ id: `locale-${item[1]}` })}</span>
|
||||
</MenuItem>
|
||||
);
|
||||
})}
|
||||
</MenuList>
|
||||
</Menu>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export { LocalePicker };
|
24
frontend/src/components/Navigation/Navigation.tsx
Normal file
24
frontend/src/components/Navigation/Navigation.tsx
Normal file
@ -0,0 +1,24 @@
|
||||
import { useDisclosure } from "@chakra-ui/react";
|
||||
import { NavigationHeader, NavigationMenu } from "components";
|
||||
|
||||
function Navigation() {
|
||||
const {
|
||||
isOpen: mobileNavIsOpen,
|
||||
onToggle: mobileNavToggle,
|
||||
onClose: mobileNavClose,
|
||||
} = useDisclosure();
|
||||
return (
|
||||
<>
|
||||
<NavigationHeader
|
||||
toggleMobileNav={mobileNavToggle}
|
||||
mobileNavIsOpen={mobileNavIsOpen}
|
||||
/>
|
||||
<NavigationMenu
|
||||
mobileNavIsOpen={mobileNavIsOpen}
|
||||
closeMobileNav={mobileNavClose}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export { Navigation };
|
113
frontend/src/components/Navigation/NavigationHeader.tsx
Normal file
113
frontend/src/components/Navigation/NavigationHeader.tsx
Normal file
@ -0,0 +1,113 @@
|
||||
import {
|
||||
Avatar,
|
||||
Box,
|
||||
Button,
|
||||
chakra,
|
||||
Container,
|
||||
Flex,
|
||||
HStack,
|
||||
Icon,
|
||||
IconButton,
|
||||
Menu,
|
||||
MenuButton,
|
||||
MenuDivider,
|
||||
MenuItem,
|
||||
MenuList,
|
||||
Text,
|
||||
useColorModeValue,
|
||||
useDisclosure,
|
||||
} from "@chakra-ui/react";
|
||||
import { ThemeSwitcher } from "components";
|
||||
import { useAuthState } from "context";
|
||||
import { useUser } from "hooks";
|
||||
import { intl } from "locale";
|
||||
import { ChangePasswordModal, ProfileModal } from "modals";
|
||||
import { FiLock, FiLogOut, FiMenu, FiUser, FiX } from "react-icons/fi";
|
||||
|
||||
interface NavigationHeaderProps {
|
||||
mobileNavIsOpen: boolean;
|
||||
toggleMobileNav: () => void;
|
||||
}
|
||||
function NavigationHeader({
|
||||
mobileNavIsOpen,
|
||||
toggleMobileNav,
|
||||
}: NavigationHeaderProps) {
|
||||
const passwordDisclosure = useDisclosure();
|
||||
const profileDisclosure = useDisclosure();
|
||||
const { data: user } = useUser("me");
|
||||
const { logout } = useAuthState();
|
||||
|
||||
return (
|
||||
<Box
|
||||
h={16}
|
||||
borderBottom="1px solid"
|
||||
borderColor={useColorModeValue("gray.200", "gray.700")}>
|
||||
<Container h="full" maxW="container.xl">
|
||||
<Flex h="full" alignItems="center" justifyContent="space-between">
|
||||
<IconButton
|
||||
display={{ base: "block", md: "none" }}
|
||||
position="relative"
|
||||
bg="transparent"
|
||||
aria-label={
|
||||
mobileNavIsOpen
|
||||
? intl.formatMessage({ id: "navigation.close" })
|
||||
: intl.formatMessage({ id: "navigation.open" })
|
||||
}
|
||||
onClick={toggleMobileNav}
|
||||
icon={<Icon as={mobileNavIsOpen ? FiX : FiMenu} />}
|
||||
/>
|
||||
<HStack height="full" paddingY={3} spacing={4}>
|
||||
<chakra.img src="/images/logo-no-text.svg" alt="" height="full" />
|
||||
<Text
|
||||
display={{ base: "none", md: "block" }}
|
||||
fontSize="2xl"
|
||||
fontWeight="bold">
|
||||
{intl.formatMessage({ id: "brand.name" })}
|
||||
</Text>
|
||||
</HStack>
|
||||
<HStack>
|
||||
<ThemeSwitcher background="transparent" />
|
||||
<Box pl={2}>
|
||||
<Menu>
|
||||
<MenuButton
|
||||
as={Button}
|
||||
rounded="full"
|
||||
variant="link"
|
||||
cursor="pointer"
|
||||
minW={0}>
|
||||
<Avatar size="sm" src={user?.gravatarUrl} />
|
||||
</MenuButton>
|
||||
<MenuList>
|
||||
<MenuItem
|
||||
icon={<Icon as={FiUser} />}
|
||||
onClick={profileDisclosure.onOpen}>
|
||||
{intl.formatMessage({ id: "profile.title" })}
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
icon={<Icon as={FiLock} />}
|
||||
onClick={passwordDisclosure.onOpen}>
|
||||
{intl.formatMessage({ id: "change-password" })}
|
||||
</MenuItem>
|
||||
<MenuDivider />
|
||||
<MenuItem onClick={logout} icon={<Icon as={FiLogOut} />}>
|
||||
{intl.formatMessage({ id: "profile.logout" })}
|
||||
</MenuItem>
|
||||
</MenuList>
|
||||
</Menu>
|
||||
</Box>
|
||||
</HStack>
|
||||
</Flex>
|
||||
</Container>
|
||||
<ProfileModal
|
||||
isOpen={profileDisclosure.isOpen}
|
||||
onClose={profileDisclosure.onClose}
|
||||
/>
|
||||
<ChangePasswordModal
|
||||
isOpen={passwordDisclosure.isOpen}
|
||||
onClose={passwordDisclosure.onClose}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export { NavigationHeader };
|
331
frontend/src/components/Navigation/NavigationMenu.tsx
Normal file
331
frontend/src/components/Navigation/NavigationMenu.tsx
Normal file
@ -0,0 +1,331 @@
|
||||
import { FC, useCallback, useMemo, ReactNode } from "react";
|
||||
|
||||
import {
|
||||
Box,
|
||||
Collapse,
|
||||
Flex,
|
||||
forwardRef,
|
||||
HStack,
|
||||
Icon,
|
||||
Link,
|
||||
Menu,
|
||||
MenuButton,
|
||||
MenuItem,
|
||||
MenuList,
|
||||
Text,
|
||||
Stack,
|
||||
useColorModeValue,
|
||||
useDisclosure,
|
||||
Container,
|
||||
useBreakpointValue,
|
||||
} from "@chakra-ui/react";
|
||||
import { intl } from "locale";
|
||||
import {
|
||||
FiHome,
|
||||
FiSettings,
|
||||
FiUser,
|
||||
FiBook,
|
||||
FiLock,
|
||||
FiShield,
|
||||
FiMonitor,
|
||||
FiChevronDown,
|
||||
} from "react-icons/fi";
|
||||
import { Link as RouterLink, useLocation } from "react-router-dom";
|
||||
|
||||
interface NavItem {
|
||||
/** Displayed label */
|
||||
label: string;
|
||||
/** Icon shown before the label */
|
||||
icon: ReactNode;
|
||||
/** Link where to navigate to */
|
||||
to?: string;
|
||||
subItems?: { label: string; to: string }[];
|
||||
}
|
||||
|
||||
const navItems: NavItem[] = [
|
||||
{
|
||||
label: intl.formatMessage({ id: "dashboard.title" }),
|
||||
icon: <Icon as={FiHome} />,
|
||||
to: "/",
|
||||
},
|
||||
{
|
||||
label: intl.formatMessage({ id: "hosts.title" }),
|
||||
icon: <Icon as={FiMonitor} />,
|
||||
to: "/hosts",
|
||||
},
|
||||
{
|
||||
label: intl.formatMessage({ id: "access-lists.title" }),
|
||||
icon: <Icon as={FiLock} />,
|
||||
to: "/access-lists",
|
||||
},
|
||||
{
|
||||
label: intl.formatMessage({ id: "ssl.title" }),
|
||||
icon: <Icon as={FiShield} />,
|
||||
subItems: [
|
||||
{
|
||||
label: intl.formatMessage({ id: "certificates.title" }),
|
||||
to: "/ssl/certificates",
|
||||
},
|
||||
{
|
||||
label: intl.formatMessage({ id: "certificate-authorities.title" }),
|
||||
to: "/ssl/authorities",
|
||||
},
|
||||
{
|
||||
label: intl.formatMessage({ id: "dns-providers.title" }),
|
||||
to: "/ssl/dns-providers",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: intl.formatMessage({ id: "audit-log.title" }),
|
||||
icon: <Icon as={FiBook} />,
|
||||
to: "/audit-log",
|
||||
},
|
||||
{
|
||||
label: intl.formatMessage({ id: "users.title" }),
|
||||
icon: <Icon as={FiUser} />,
|
||||
to: "/users",
|
||||
},
|
||||
{
|
||||
label: intl.formatMessage({ id: "settings.title" }),
|
||||
icon: <Icon as={FiSettings} />,
|
||||
subItems: [
|
||||
{
|
||||
label: intl.formatMessage({ id: "general-settings.title" }),
|
||||
to: "/settings/general",
|
||||
},
|
||||
{
|
||||
label: intl.formatMessage({ id: "host-templates.title" }),
|
||||
to: "/settings/host-templates",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
interface NavigationMenuProps {
|
||||
/** Navigation is currently hidden on mobile */
|
||||
mobileNavIsOpen: boolean;
|
||||
closeMobileNav: () => void;
|
||||
}
|
||||
function NavigationMenu({
|
||||
mobileNavIsOpen,
|
||||
closeMobileNav,
|
||||
}: NavigationMenuProps) {
|
||||
const isMobile = useBreakpointValue({ base: true, md: false });
|
||||
return (
|
||||
<>
|
||||
{isMobile ? (
|
||||
<Collapse in={mobileNavIsOpen}>
|
||||
<MobileNavigation closeMobileNav={closeMobileNav} />
|
||||
</Collapse>
|
||||
) : (
|
||||
<DesktopNavigation />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** Single tab element for desktop navigation */
|
||||
type NavTabProps = Omit<NavItem, "subItems"> & { active?: boolean };
|
||||
const NavTab = forwardRef<NavTabProps, "a">(
|
||||
({ label, icon, to, active, ...props }, ref) => {
|
||||
const linkColor = useColorModeValue("gray.500", "gray.200");
|
||||
const linkHoverColor = useColorModeValue("gray.900", "white");
|
||||
return (
|
||||
<Link
|
||||
as={RouterLink}
|
||||
ref={ref}
|
||||
height={12}
|
||||
to={to ?? "#"}
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
borderBottom="1px solid"
|
||||
borderBottomColor={active ? linkHoverColor : "transparent"}
|
||||
color={active ? linkHoverColor : linkColor}
|
||||
_hover={{
|
||||
textDecoration: "none",
|
||||
color: linkHoverColor,
|
||||
borderBottomColor: linkHoverColor,
|
||||
}}
|
||||
{...props}>
|
||||
{icon}
|
||||
<Text as="span" marginLeft={2}>
|
||||
{label}
|
||||
</Text>
|
||||
</Link>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
const DesktopNavigation: FC = () => {
|
||||
const path = useLocation().pathname;
|
||||
const activeNavItemIndex = useMemo(
|
||||
() =>
|
||||
navItems.findIndex((item) => {
|
||||
// Find the nav item whose location / sub items location is the beginning of the currently active path
|
||||
if (item.to) {
|
||||
// console.debug(item.to, path);
|
||||
if (item.to === "/") {
|
||||
return path === item.to;
|
||||
}
|
||||
return path.startsWith(item.to !== "" ? item.to : "/dashboard");
|
||||
} else if (item.subItems) {
|
||||
return item.subItems.some((subItem) => path.startsWith(subItem.to));
|
||||
}
|
||||
return false;
|
||||
}),
|
||||
[path],
|
||||
);
|
||||
|
||||
return (
|
||||
<Box
|
||||
display={{ base: "none", md: "block" }}
|
||||
overflowY="visible"
|
||||
overflowX="auto"
|
||||
whiteSpace="nowrap"
|
||||
borderBottom="1px solid"
|
||||
borderColor={useColorModeValue("gray.200", "gray.700")}>
|
||||
<Container h="full" maxW="container.xl">
|
||||
<HStack spacing={8}>
|
||||
{navItems.map((navItem, index) => {
|
||||
const { subItems, ...propsWithoutSubItems } = navItem;
|
||||
const additionalProps: Partial<NavTabProps> = {};
|
||||
if (index === activeNavItemIndex) {
|
||||
additionalProps["active"] = true;
|
||||
}
|
||||
if (subItems) {
|
||||
return (
|
||||
<Menu key={`mainnav${index}`}>
|
||||
<MenuButton
|
||||
as={NavTab}
|
||||
{...propsWithoutSubItems}
|
||||
{...additionalProps}
|
||||
/>
|
||||
{subItems && (
|
||||
<MenuList>
|
||||
{subItems.map((item, subIndex) => (
|
||||
<MenuItem
|
||||
as={RouterLink}
|
||||
to={item.to}
|
||||
key={`mainnav${index}-${subIndex}`}>
|
||||
{item.label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</MenuList>
|
||||
)}
|
||||
</Menu>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<NavTab
|
||||
key={`mainnav${index}`}
|
||||
{...propsWithoutSubItems}
|
||||
{...additionalProps}
|
||||
/>
|
||||
);
|
||||
}
|
||||
})}
|
||||
</HStack>
|
||||
</Container>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
const MobileNavigation: FC<Pick<NavigationMenuProps, "closeMobileNav">> = ({
|
||||
closeMobileNav,
|
||||
}) => {
|
||||
return (
|
||||
<Stack
|
||||
p={4}
|
||||
display={{ md: "none" }}
|
||||
borderBottom="1px solid"
|
||||
borderColor={useColorModeValue("gray.200", "gray.700")}>
|
||||
{navItems.map((navItem, index) => (
|
||||
<MobileNavItem
|
||||
key={`mainmobilenav${index}`}
|
||||
index={index}
|
||||
closeMobileNav={closeMobileNav}
|
||||
{...navItem}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
const MobileNavItem: FC<
|
||||
NavItem & {
|
||||
index: number;
|
||||
closeMobileNav: NavigationMenuProps["closeMobileNav"];
|
||||
}
|
||||
> = ({ closeMobileNav, ...props }) => {
|
||||
const { isOpen, onToggle } = useDisclosure();
|
||||
|
||||
const onClickHandler = useCallback(() => {
|
||||
if (props.subItems) {
|
||||
// Toggle accordeon
|
||||
onToggle();
|
||||
} else {
|
||||
// Close menu on navigate
|
||||
closeMobileNav();
|
||||
}
|
||||
}, [closeMobileNav, onToggle, props.subItems]);
|
||||
|
||||
return (
|
||||
<Stack spacing={4} onClick={onClickHandler}>
|
||||
<Box>
|
||||
<Flex
|
||||
py={2}
|
||||
as={RouterLink}
|
||||
to={props.to ?? "#"}
|
||||
justify="space-between"
|
||||
align="center"
|
||||
_hover={{
|
||||
textDecoration: "none",
|
||||
}}>
|
||||
<Box display="flex" alignItems="center">
|
||||
{props.icon}
|
||||
<Text as="span" marginLeft={2}>
|
||||
{props.label}
|
||||
</Text>
|
||||
</Box>
|
||||
{props.subItems && (
|
||||
<Icon
|
||||
as={FiChevronDown}
|
||||
transition="all .25s ease-in-out"
|
||||
transform={isOpen ? "rotate(180deg)" : ""}
|
||||
w={6}
|
||||
h={6}
|
||||
/>
|
||||
)}
|
||||
</Flex>
|
||||
|
||||
<Collapse
|
||||
in={isOpen}
|
||||
animateOpacity
|
||||
style={{ marginTop: "0 !important" }}>
|
||||
<Stack
|
||||
mt={1}
|
||||
pl={4}
|
||||
borderLeft={1}
|
||||
borderStyle="solid"
|
||||
borderColor={useColorModeValue("gray.200", "gray.700")}
|
||||
align="start">
|
||||
{props.subItems &&
|
||||
props.subItems.map((subItem, subIndex) => (
|
||||
<Link
|
||||
as={RouterLink}
|
||||
key={`mainmobilenav${props.index}-${subIndex}`}
|
||||
py={2}
|
||||
onClick={closeMobileNav}
|
||||
to={subItem.to}>
|
||||
{subItem.label}
|
||||
</Link>
|
||||
))}
|
||||
</Stack>
|
||||
</Collapse>
|
||||
</Box>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export { NavigationMenu };
|
3
frontend/src/components/Navigation/index.ts
Normal file
3
frontend/src/components/Navigation/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export * from "./Navigation";
|
||||
export * from "./NavigationHeader";
|
||||
export * from "./NavigationMenu";
|
@ -0,0 +1,36 @@
|
||||
import { MouseEventHandler } from "react";
|
||||
|
||||
import { Heading, Stack, Text, useColorModeValue } from "@chakra-ui/react";
|
||||
import { intl } from "locale";
|
||||
|
||||
interface AdminPermissionSelectorProps {
|
||||
selected?: boolean;
|
||||
onClick: MouseEventHandler<HTMLElement>;
|
||||
}
|
||||
|
||||
function AdminPermissionSelector({
|
||||
selected,
|
||||
onClick,
|
||||
}: AdminPermissionSelectorProps) {
|
||||
return (
|
||||
<Stack
|
||||
onClick={onClick}
|
||||
style={{ cursor: "pointer", opacity: selected ? 1 : 0.4 }}
|
||||
borderWidth="1px"
|
||||
borderRadius="lg"
|
||||
w={{ sm: "100%" }}
|
||||
mb={2}
|
||||
p={4}
|
||||
bg={useColorModeValue("white", "gray.900")}
|
||||
boxShadow={selected ? "2xl" : "base"}>
|
||||
<Heading fontSize="2xl" fontFamily="body">
|
||||
{intl.formatMessage({ id: "full-access" })}
|
||||
</Heading>
|
||||
<Text color={useColorModeValue("gray.700", "gray.400")}>
|
||||
{intl.formatMessage({ id: "full-access.description" })}
|
||||
</Text>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
export { AdminPermissionSelector };
|
285
frontend/src/components/Permissions/PermissionSelector.tsx
Normal file
285
frontend/src/components/Permissions/PermissionSelector.tsx
Normal file
@ -0,0 +1,285 @@
|
||||
import { ChangeEvent, MouseEventHandler } from "react";
|
||||
|
||||
import {
|
||||
Flex,
|
||||
Heading,
|
||||
Select,
|
||||
Stack,
|
||||
Text,
|
||||
useColorModeValue,
|
||||
} from "@chakra-ui/react";
|
||||
import { intl } from "locale";
|
||||
|
||||
interface PermissionSelectorProps {
|
||||
capabilities: string[];
|
||||
selected?: boolean;
|
||||
onClick: MouseEventHandler<HTMLElement>;
|
||||
onChange: (i: string[]) => any;
|
||||
}
|
||||
|
||||
function PermissionSelector({
|
||||
capabilities,
|
||||
selected,
|
||||
onClick,
|
||||
onChange,
|
||||
}: PermissionSelectorProps) {
|
||||
const textColor = useColorModeValue("gray.700", "gray.400");
|
||||
|
||||
const onSelectChange = ({ target }: ChangeEvent<HTMLSelectElement>) => {
|
||||
// remove all items starting with target.name
|
||||
const i: string[] = [];
|
||||
const re = new RegExp(`^${target.name}\\.`, "g");
|
||||
capabilities.forEach((capability) => {
|
||||
if (!capability.match(re)) {
|
||||
i.push(capability);
|
||||
}
|
||||
});
|
||||
|
||||
// add a new item, if value is something, and doesn't already exist
|
||||
if (target.value) {
|
||||
const c = `${target.name}.${target.value}`;
|
||||
if (i.indexOf(c) === -1) {
|
||||
i.push(c);
|
||||
}
|
||||
}
|
||||
|
||||
onChange(i);
|
||||
};
|
||||
|
||||
const getDefaultValue = (c: string): string => {
|
||||
if (capabilities.indexOf(`${c}.manage`) !== -1) {
|
||||
return "manage";
|
||||
}
|
||||
if (capabilities.indexOf(`${c}.view`) !== -1) {
|
||||
return "view";
|
||||
}
|
||||
return "";
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack
|
||||
onClick={onClick}
|
||||
style={{ cursor: "pointer", opacity: selected ? 1 : 0.4 }}
|
||||
borderWidth="1px"
|
||||
borderRadius="lg"
|
||||
w={{ sm: "100%" }}
|
||||
p={4}
|
||||
bg={useColorModeValue("white", "gray.900")}
|
||||
boxShadow={selected ? "2xl" : "base"}>
|
||||
<Heading fontSize="2xl" fontFamily="body">
|
||||
{intl.formatMessage({ id: "restricted-access" })}
|
||||
</Heading>
|
||||
{selected ? (
|
||||
<Stack spacing={3}>
|
||||
<Stack direction={{ base: "column", md: "row" }}>
|
||||
<Flex flex={1}>
|
||||
{intl.formatMessage({ id: "access-lists.title" })}
|
||||
</Flex>
|
||||
<Flex flex={1}>
|
||||
<Select
|
||||
defaultValue={getDefaultValue("access-lists")}
|
||||
onChange={onSelectChange}
|
||||
name="access-lists"
|
||||
size="sm"
|
||||
variant="filled"
|
||||
disabled={!selected}>
|
||||
<option value="">
|
||||
{intl.formatMessage({ id: "no-access" })}
|
||||
</option>
|
||||
<option value="manage">
|
||||
{intl.formatMessage({ id: "full-access" })}
|
||||
</option>
|
||||
<option value="view">
|
||||
{intl.formatMessage({ id: "view-only" })}
|
||||
</option>
|
||||
</Select>
|
||||
</Flex>
|
||||
</Stack>
|
||||
<Stack direction={{ base: "column", md: "row" }}>
|
||||
<Flex flex={1}>
|
||||
{intl.formatMessage({ id: "audit-log.title" })}
|
||||
</Flex>
|
||||
<Flex flex={1}>
|
||||
<Select
|
||||
defaultValue={getDefaultValue("audit-log")}
|
||||
onChange={onSelectChange}
|
||||
name="audit-log"
|
||||
size="sm"
|
||||
variant="filled"
|
||||
disabled={!selected}>
|
||||
<option value="">
|
||||
{intl.formatMessage({ id: "no-access" })}
|
||||
</option>
|
||||
<option value="view">
|
||||
{intl.formatMessage({ id: "view-only" })}
|
||||
</option>
|
||||
</Select>
|
||||
</Flex>
|
||||
</Stack>
|
||||
<Stack direction={{ base: "column", md: "row" }}>
|
||||
<Flex flex={1}>
|
||||
{intl.formatMessage({ id: "certificates.title" })}
|
||||
</Flex>
|
||||
<Flex flex={1}>
|
||||
<Select
|
||||
defaultValue={getDefaultValue("certificates")}
|
||||
onChange={onSelectChange}
|
||||
name="certificates"
|
||||
size="sm"
|
||||
variant="filled"
|
||||
disabled={!selected}>
|
||||
<option value="">
|
||||
{intl.formatMessage({ id: "no-access" })}
|
||||
</option>
|
||||
<option value="manage">
|
||||
{intl.formatMessage({ id: "full-access" })}
|
||||
</option>
|
||||
<option value="view">
|
||||
{intl.formatMessage({ id: "view-only" })}
|
||||
</option>
|
||||
</Select>
|
||||
</Flex>
|
||||
</Stack>
|
||||
<Stack direction={{ base: "column", md: "row" }}>
|
||||
<Flex flex={1}>
|
||||
{intl.formatMessage({ id: "certificate-authorities.title" })}
|
||||
</Flex>
|
||||
<Flex flex={1}>
|
||||
<Select
|
||||
defaultValue={getDefaultValue("certificate-authorities")}
|
||||
onChange={onSelectChange}
|
||||
name="certificate-authorities"
|
||||
size="sm"
|
||||
variant="filled"
|
||||
disabled={!selected}>
|
||||
<option value="">
|
||||
{intl.formatMessage({ id: "no-access" })}
|
||||
</option>
|
||||
<option value="manage">
|
||||
{intl.formatMessage({ id: "full-access" })}
|
||||
</option>
|
||||
<option value="view">
|
||||
{intl.formatMessage({ id: "view-only" })}
|
||||
</option>
|
||||
</Select>
|
||||
</Flex>
|
||||
</Stack>
|
||||
<Stack direction={{ base: "column", md: "row" }}>
|
||||
<Flex flex={1}>
|
||||
{intl.formatMessage({ id: "dns-providers.title" })}
|
||||
</Flex>
|
||||
<Flex flex={1}>
|
||||
<Select
|
||||
defaultValue={getDefaultValue("dns-providers")}
|
||||
onChange={onSelectChange}
|
||||
name="dns-providers"
|
||||
size="sm"
|
||||
variant="filled"
|
||||
disabled={!selected}>
|
||||
<option value="">
|
||||
{intl.formatMessage({ id: "no-access" })}
|
||||
</option>
|
||||
<option value="manage">
|
||||
{intl.formatMessage({ id: "full-access" })}
|
||||
</option>
|
||||
<option value="view">
|
||||
{intl.formatMessage({ id: "view-only" })}
|
||||
</option>
|
||||
</Select>
|
||||
</Flex>
|
||||
</Stack>
|
||||
<Stack direction={{ base: "column", md: "row" }}>
|
||||
<Flex flex={1}>{intl.formatMessage({ id: "hosts.title" })}</Flex>
|
||||
<Flex flex={1}>
|
||||
<Select
|
||||
defaultValue={getDefaultValue("hosts")}
|
||||
onChange={onSelectChange}
|
||||
name="hosts"
|
||||
size="sm"
|
||||
variant="filled"
|
||||
disabled={!selected}>
|
||||
<option value="">
|
||||
{intl.formatMessage({ id: "no-access" })}
|
||||
</option>
|
||||
<option value="manage">
|
||||
{intl.formatMessage({ id: "full-access" })}
|
||||
</option>
|
||||
<option value="view">
|
||||
{intl.formatMessage({ id: "view-only" })}
|
||||
</option>
|
||||
</Select>
|
||||
</Flex>
|
||||
</Stack>
|
||||
<Stack direction={{ base: "column", md: "row" }}>
|
||||
<Flex flex={1}>
|
||||
{intl.formatMessage({ id: "host-templates.title" })}
|
||||
</Flex>
|
||||
<Flex flex={1}>
|
||||
<Select
|
||||
defaultValue={getDefaultValue("host-templates")}
|
||||
onChange={onSelectChange}
|
||||
name="host-templates"
|
||||
size="sm"
|
||||
variant="filled"
|
||||
disabled={!selected}>
|
||||
<option value="">
|
||||
{intl.formatMessage({ id: "no-access" })}
|
||||
</option>
|
||||
<option value="manage">
|
||||
{intl.formatMessage({ id: "full-access" })}
|
||||
</option>
|
||||
<option value="view">
|
||||
{intl.formatMessage({ id: "view-only" })}
|
||||
</option>
|
||||
</Select>
|
||||
</Flex>
|
||||
</Stack>
|
||||
<Stack direction={{ base: "column", md: "row" }}>
|
||||
<Flex flex={1}>{intl.formatMessage({ id: "settings.title" })}</Flex>
|
||||
<Flex flex={1}>
|
||||
<Select
|
||||
defaultValue={getDefaultValue("settings")}
|
||||
onChange={onSelectChange}
|
||||
name="settings"
|
||||
size="sm"
|
||||
variant="filled"
|
||||
disabled={!selected}>
|
||||
<option value="">
|
||||
{intl.formatMessage({ id: "no-access" })}
|
||||
</option>
|
||||
<option value="manage">
|
||||
{intl.formatMessage({ id: "full-access" })}
|
||||
</option>
|
||||
</Select>
|
||||
</Flex>
|
||||
</Stack>
|
||||
<Stack direction={{ base: "column", md: "row" }}>
|
||||
<Flex flex={1}>{intl.formatMessage({ id: "users.title" })}</Flex>
|
||||
<Flex flex={1}>
|
||||
<Select
|
||||
defaultValue={getDefaultValue("users")}
|
||||
onChange={onSelectChange}
|
||||
name="users"
|
||||
size="sm"
|
||||
variant="filled"
|
||||
disabled={!selected}>
|
||||
<option value="">
|
||||
{intl.formatMessage({ id: "no-access" })}
|
||||
</option>
|
||||
<option value="manage">
|
||||
{intl.formatMessage({ id: "full-access" })}
|
||||
</option>
|
||||
</Select>
|
||||
</Flex>
|
||||
</Stack>
|
||||
</Stack>
|
||||
) : (
|
||||
<Text color={textColor}>
|
||||
{intl.formatMessage({ id: "restricted-access.description" })}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
export { PermissionSelector };
|
2
frontend/src/components/Permissions/index.ts
Normal file
2
frontend/src/components/Permissions/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from "./AdminPermissionSelector";
|
||||
export * from "./PermissionSelector";
|
20
frontend/src/components/PrettyButton.tsx
Normal file
20
frontend/src/components/PrettyButton.tsx
Normal file
@ -0,0 +1,20 @@
|
||||
import { Button, ButtonProps } from "@chakra-ui/react";
|
||||
|
||||
function PrettyButton(props: ButtonProps) {
|
||||
return (
|
||||
<Button
|
||||
type="submit"
|
||||
fontFamily="heading"
|
||||
bgGradient="linear(to-r, red.400,pink.400)"
|
||||
color="white"
|
||||
_hover={{
|
||||
bgGradient: "linear(to-r, red.400,pink.400)",
|
||||
boxShadow: "xl",
|
||||
}}
|
||||
{...props}>
|
||||
{props.children}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
export { PrettyButton };
|
32
frontend/src/components/SiteWrapper.tsx
Normal file
32
frontend/src/components/SiteWrapper.tsx
Normal file
@ -0,0 +1,32 @@
|
||||
import { ReactNode } from "react";
|
||||
|
||||
import { Box, Container } from "@chakra-ui/react";
|
||||
import { Footer, Navigation } from "components";
|
||||
|
||||
interface Props {
|
||||
children?: ReactNode;
|
||||
}
|
||||
function SiteWrapper({ children }: Props) {
|
||||
return (
|
||||
<Box display="flex" flexDir="column" height="100vh">
|
||||
<Box flexShrink={0}>
|
||||
<Navigation />
|
||||
</Box>
|
||||
<Box flex="1 0 auto" overflow="auto">
|
||||
<Container
|
||||
as="main"
|
||||
maxW="container.xl"
|
||||
overflowY="auto"
|
||||
py={4}
|
||||
h="full">
|
||||
{children}
|
||||
</Container>
|
||||
</Box>
|
||||
<Box flexShrink={0}>
|
||||
<Footer />
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export { SiteWrapper };
|
9
frontend/src/components/SpinnerPage.tsx
Normal file
9
frontend/src/components/SpinnerPage.tsx
Normal file
@ -0,0 +1,9 @@
|
||||
import { Flex, Spinner } from "@chakra-ui/react";
|
||||
|
||||
export function SpinnerPage() {
|
||||
return (
|
||||
<Flex alignItems="center" justifyContent="center" h="full">
|
||||
<Spinner />
|
||||
</Flex>
|
||||
);
|
||||
}
|
225
frontend/src/components/Table/Formatters.tsx
Normal file
225
frontend/src/components/Table/Formatters.tsx
Normal file
@ -0,0 +1,225 @@
|
||||
import { Avatar, Badge, Text, Tooltip } from "@chakra-ui/react";
|
||||
import { RowAction, RowActionsMenu } from "components";
|
||||
import { intl } from "locale";
|
||||
import getNiceDNSProvider from "modules/Acmesh";
|
||||
|
||||
function ActionsFormatter(rowActions: RowAction[]) {
|
||||
const formatCell = (instance: any) => {
|
||||
return <RowActionsMenu data={instance.row.original} actions={rowActions} />;
|
||||
};
|
||||
|
||||
return formatCell;
|
||||
}
|
||||
|
||||
function BooleanFormatter() {
|
||||
const formatCell = ({ value }: any) => {
|
||||
return (
|
||||
<Badge color={value ? "cyan.500" : "red.400"}>
|
||||
{value ? "true" : "false"}
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
return formatCell;
|
||||
}
|
||||
|
||||
function CapabilitiesFormatter() {
|
||||
const formatCell = ({ row, value }: any) => {
|
||||
const style = {} as any;
|
||||
if (row?.original?.isDisabled) {
|
||||
style.textDecoration = "line-through";
|
||||
}
|
||||
|
||||
if (row?.original?.isSystem) {
|
||||
return (
|
||||
<Badge color="orange.400" style={style}>
|
||||
{intl.formatMessage({ id: "capability.system" })}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
if (value?.indexOf("full-admin") !== -1) {
|
||||
return (
|
||||
<Badge color="teal.300" style={style}>
|
||||
{intl.formatMessage({ id: "capability.full-admin" })}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
if (value?.length) {
|
||||
const strs: string[] = [];
|
||||
value.map((c: string) => {
|
||||
strs.push(intl.formatMessage({ id: `capability.${c}` }));
|
||||
return null;
|
||||
});
|
||||
|
||||
return (
|
||||
<Tooltip label={strs.join(", \n")}>
|
||||
<Badge color="cyan.500" style={style}>
|
||||
{intl.formatMessage(
|
||||
{ id: "capability-count" },
|
||||
{ count: value.length },
|
||||
)}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
return formatCell;
|
||||
}
|
||||
|
||||
function CertificateStatusFormatter() {
|
||||
const formatCell = ({ value }: any) => {
|
||||
return (
|
||||
<Badge color={value ? "cyan.500" : "red.400"}>
|
||||
{value
|
||||
? intl.formatMessage({ id: "ready" })
|
||||
: intl.formatMessage({ id: "setup-required" })}
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
return formatCell;
|
||||
}
|
||||
|
||||
function DisabledFormatter() {
|
||||
const formatCell = ({ value, row }: any) => {
|
||||
if (row?.original?.isDisabled) {
|
||||
return (
|
||||
<Text color="red.500">
|
||||
<Tooltip label={intl.formatMessage({ id: "user.disabled" })}>
|
||||
{value}
|
||||
</Tooltip>
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
return formatCell;
|
||||
}
|
||||
|
||||
function DNSProviderFormatter() {
|
||||
const formatCell = ({ value }: any) => {
|
||||
return getNiceDNSProvider(value);
|
||||
};
|
||||
|
||||
return formatCell;
|
||||
}
|
||||
|
||||
function DomainsFormatter() {
|
||||
const formatCell = ({ value }: any) => {
|
||||
if (value?.length > 0) {
|
||||
return (
|
||||
<>
|
||||
{value.map((dom: string, idx: number) => {
|
||||
return (
|
||||
<Badge key={`domain-${idx}`} color="yellow.400">
|
||||
{dom}
|
||||
</Badge>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
return <Badge color="red.400">No domains!</Badge>;
|
||||
};
|
||||
|
||||
return formatCell;
|
||||
}
|
||||
|
||||
function GravatarFormatter() {
|
||||
const formatCell = ({ value }: any) => {
|
||||
return <Avatar size="sm" src={value} />;
|
||||
};
|
||||
|
||||
return formatCell;
|
||||
}
|
||||
|
||||
function HostStatusFormatter() {
|
||||
const formatCell = ({ row }: any) => {
|
||||
if (row.original.isDisabled) {
|
||||
return (
|
||||
<Badge color="red.400">{intl.formatMessage({ id: "disabled" })}</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
if (row.original.certificateId) {
|
||||
if (row.original.certificate.status === "provided") {
|
||||
return (
|
||||
<Badge color="green.400">
|
||||
{row.original.sslForced
|
||||
? intl.formatMessage({ id: "https-only" })
|
||||
: intl.formatMessage({ id: "http-https" })}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
if (row.original.certificate.status === "error") {
|
||||
return (
|
||||
<Tooltip label={row.original.certificate.errorMessage}>
|
||||
<Badge color="red.400">{intl.formatMessage({ id: "error" })}</Badge>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Badge color="cyan.400">
|
||||
{intl.formatMessage({
|
||||
id: `certificate.${row.original.certificate.status}`,
|
||||
})}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Badge color="orange.400">
|
||||
{intl.formatMessage({ id: "http-only" })}
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
return formatCell;
|
||||
}
|
||||
|
||||
function HostTypeFormatter() {
|
||||
const formatCell = ({ value }: any) => {
|
||||
return intl.formatMessage({ id: `host-type.${value}` });
|
||||
};
|
||||
|
||||
return formatCell;
|
||||
}
|
||||
|
||||
function IDFormatter() {
|
||||
const formatCell = ({ value }: any) => {
|
||||
return <span className="text-muted">{value}</span>;
|
||||
};
|
||||
|
||||
return formatCell;
|
||||
}
|
||||
|
||||
function SecondsFormatter() {
|
||||
const formatCell = ({ value }: any) => {
|
||||
return intl.formatMessage({ id: "seconds" }, { seconds: value });
|
||||
};
|
||||
|
||||
return formatCell;
|
||||
}
|
||||
|
||||
export {
|
||||
ActionsFormatter,
|
||||
BooleanFormatter,
|
||||
CapabilitiesFormatter,
|
||||
CertificateStatusFormatter,
|
||||
DisabledFormatter,
|
||||
DNSProviderFormatter,
|
||||
DomainsFormatter,
|
||||
GravatarFormatter,
|
||||
HostStatusFormatter,
|
||||
HostTypeFormatter,
|
||||
IDFormatter,
|
||||
SecondsFormatter,
|
||||
};
|
70
frontend/src/components/Table/RowActionsMenu.tsx
Normal file
70
frontend/src/components/Table/RowActionsMenu.tsx
Normal file
@ -0,0 +1,70 @@
|
||||
import { ReactNode } from "react";
|
||||
|
||||
import {
|
||||
Menu,
|
||||
MenuButton,
|
||||
MenuList,
|
||||
MenuItem,
|
||||
IconButton,
|
||||
} from "@chakra-ui/react";
|
||||
import { FiMoreVertical } from "react-icons/fi";
|
||||
|
||||
// A row action is a single menu item for the actions column
|
||||
export interface RowAction {
|
||||
title: string;
|
||||
onClick: (e: any, data: any) => any;
|
||||
show?: (data: any) => any;
|
||||
disabled?: (data: any) => any;
|
||||
icon?: any;
|
||||
}
|
||||
|
||||
interface RowActionsProps {
|
||||
/**
|
||||
* Row Data
|
||||
*/
|
||||
data: any;
|
||||
/**
|
||||
* Actions
|
||||
*/
|
||||
actions: RowAction[];
|
||||
}
|
||||
function RowActionsMenu({ data, actions }: RowActionsProps) {
|
||||
const elms: ReactNode[] = [];
|
||||
actions.map((action) => {
|
||||
if (!action.show || action.show(data)) {
|
||||
const disabled = action.disabled && action.disabled(data);
|
||||
elms.push(
|
||||
<MenuItem
|
||||
key={`action-${action.title}`}
|
||||
icon={action.icon}
|
||||
isDisabled={disabled}
|
||||
onClick={(e: any) => {
|
||||
action.onClick(e, data);
|
||||
}}>
|
||||
{action.title}
|
||||
</MenuItem>,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
if (!elms.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ textAlign: "right" }}>
|
||||
<Menu>
|
||||
<MenuButton
|
||||
as={IconButton}
|
||||
aria-label="Actions"
|
||||
icon={<FiMoreVertical />}
|
||||
variant="outline"
|
||||
/>
|
||||
<MenuList>{elms}</MenuList>
|
||||
</Menu>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { RowActionsMenu };
|
57
frontend/src/components/Table/TableHelpers.ts
Normal file
57
frontend/src/components/Table/TableHelpers.ts
Normal file
@ -0,0 +1,57 @@
|
||||
export interface TablePagination {
|
||||
limit: number;
|
||||
offset: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface TableSortBy {
|
||||
id: string;
|
||||
desc: boolean;
|
||||
}
|
||||
|
||||
export interface TableFilter {
|
||||
id: string;
|
||||
value: any;
|
||||
}
|
||||
|
||||
const tableEvents = {
|
||||
FILTERS_CHANGED: "FILTERS_CHANGED",
|
||||
PAGE_CHANGED: "PAGE_CHANGED",
|
||||
PAGE_SIZE_CHANGED: "PAGE_SIZE_CHANGED",
|
||||
TOTAL_COUNT_CHANGED: "TOTAL_COUNT_CHANGED",
|
||||
SORT_CHANGED: "SORT_CHANGED",
|
||||
};
|
||||
|
||||
const tableEventReducer = (state: any, { type, payload }: any) => {
|
||||
switch (type) {
|
||||
case tableEvents.PAGE_CHANGED:
|
||||
return {
|
||||
...state,
|
||||
offset: payload * state.limit,
|
||||
};
|
||||
case tableEvents.PAGE_SIZE_CHANGED:
|
||||
return {
|
||||
...state,
|
||||
limit: payload,
|
||||
};
|
||||
case tableEvents.TOTAL_COUNT_CHANGED:
|
||||
return {
|
||||
...state,
|
||||
total: payload,
|
||||
};
|
||||
case tableEvents.SORT_CHANGED:
|
||||
return {
|
||||
...state,
|
||||
sortBy: payload,
|
||||
};
|
||||
case tableEvents.FILTERS_CHANGED:
|
||||
return {
|
||||
...state,
|
||||
filters: payload,
|
||||
};
|
||||
default:
|
||||
throw new Error(`Unhandled action type: ${type}`);
|
||||
}
|
||||
};
|
||||
|
||||
export { tableEvents, tableEventReducer };
|
246
frontend/src/components/Table/TableLayout.tsx
Normal file
246
frontend/src/components/Table/TableLayout.tsx
Normal file
@ -0,0 +1,246 @@
|
||||
import { ReactNode } from "react";
|
||||
|
||||
import {
|
||||
ButtonGroup,
|
||||
Center,
|
||||
Flex,
|
||||
HStack,
|
||||
IconButton,
|
||||
Link,
|
||||
Select,
|
||||
Table,
|
||||
Tbody,
|
||||
Td,
|
||||
Text,
|
||||
Th,
|
||||
Thead,
|
||||
Tr,
|
||||
VStack,
|
||||
} from "@chakra-ui/react";
|
||||
import { TablePagination } from "components";
|
||||
import { intl } from "locale";
|
||||
import {
|
||||
FiChevronsLeft,
|
||||
FiChevronLeft,
|
||||
FiChevronsRight,
|
||||
FiChevronRight,
|
||||
FiChevronDown,
|
||||
FiChevronUp,
|
||||
FiX,
|
||||
} from "react-icons/fi";
|
||||
|
||||
export interface TableLayoutProps {
|
||||
pagination: TablePagination;
|
||||
getTableProps: any;
|
||||
getTableBodyProps: any;
|
||||
headerGroups: any;
|
||||
rows: any;
|
||||
prepareRow: any;
|
||||
gotoPage: any;
|
||||
canPreviousPage: any;
|
||||
previousPage: any;
|
||||
canNextPage: any;
|
||||
setPageSize: any;
|
||||
nextPage: any;
|
||||
pageCount: any;
|
||||
pageOptions: any;
|
||||
visibleColumns: any;
|
||||
setAllFilters: any;
|
||||
state: any;
|
||||
}
|
||||
function TableLayout({
|
||||
pagination,
|
||||
getTableProps,
|
||||
getTableBodyProps,
|
||||
headerGroups,
|
||||
rows,
|
||||
prepareRow,
|
||||
gotoPage,
|
||||
canPreviousPage,
|
||||
previousPage,
|
||||
canNextPage,
|
||||
setPageSize,
|
||||
nextPage,
|
||||
pageCount,
|
||||
pageOptions,
|
||||
visibleColumns,
|
||||
setAllFilters,
|
||||
state,
|
||||
}: TableLayoutProps) {
|
||||
const currentPage = state.pageIndex + 1;
|
||||
const getPageList = () => {
|
||||
const list = [];
|
||||
for (let x = 0; x < pageOptions.length; x++) {
|
||||
list.push(
|
||||
<option
|
||||
key={`table-pagination-page-${x}`}
|
||||
value={x + 1}
|
||||
selected={currentPage === x + 1}>
|
||||
{x + 1}
|
||||
</option>,
|
||||
);
|
||||
}
|
||||
return list;
|
||||
};
|
||||
|
||||
const renderEmpty = (): ReactNode => {
|
||||
return (
|
||||
<Tr>
|
||||
<Td colSpan={visibleColumns.length}>
|
||||
<Center>
|
||||
{state?.filters?.length
|
||||
? intl.formatMessage(
|
||||
{ id: "tables.no-items-with-filters" },
|
||||
{ count: state.filters.length },
|
||||
)
|
||||
: intl.formatMessage({ id: "tables.no-items" })}
|
||||
</Center>
|
||||
</Td>
|
||||
</Tr>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Table {...getTableProps()}>
|
||||
<Thead>
|
||||
{headerGroups.map((headerGroup: any) => (
|
||||
<Tr {...headerGroup.getHeaderGroupProps()}>
|
||||
{headerGroup.headers.map((column: any) => (
|
||||
<Th
|
||||
userSelect="none"
|
||||
className={column.className}
|
||||
isNumeric={column.isNumeric}>
|
||||
<Flex alignItems="center">
|
||||
<HStack mx={6} justifyContent="space-between">
|
||||
<Text
|
||||
{...column.getHeaderProps(
|
||||
column.sortable && column.getSortByToggleProps(),
|
||||
)}>
|
||||
{column.render("Header")}
|
||||
</Text>
|
||||
{column.sortable && column.isSorted ? (
|
||||
column.isSortedDesc ? (
|
||||
<FiChevronDown />
|
||||
) : (
|
||||
<FiChevronUp />
|
||||
)
|
||||
) : null}
|
||||
{column.Filter ? column.render("Filter") : null}
|
||||
</HStack>
|
||||
</Flex>
|
||||
</Th>
|
||||
))}
|
||||
</Tr>
|
||||
))}
|
||||
</Thead>
|
||||
<Tbody {...getTableBodyProps()}>
|
||||
{rows.length
|
||||
? rows.map((row: any) => {
|
||||
prepareRow(row);
|
||||
return (
|
||||
<Tr {...row.getRowProps()}>
|
||||
{row.cells.map((cell: any) => (
|
||||
<Td
|
||||
{...cell.getCellProps([
|
||||
{
|
||||
className: cell.column.className,
|
||||
},
|
||||
])}>
|
||||
{cell.render("Cell")}
|
||||
</Td>
|
||||
))}
|
||||
</Tr>
|
||||
);
|
||||
})
|
||||
: renderEmpty()}
|
||||
</Tbody>
|
||||
</Table>
|
||||
<HStack mx={6} my={4} justifyContent="space-between">
|
||||
<VStack align="left">
|
||||
<Text color="gray.500">
|
||||
{rows.length
|
||||
? intl.formatMessage(
|
||||
{ id: "tables.pagination-counts" },
|
||||
{
|
||||
start: pagination.offset + 1,
|
||||
end: Math.min(
|
||||
pagination.total,
|
||||
pagination.offset + pagination.limit,
|
||||
),
|
||||
total: pagination.total,
|
||||
},
|
||||
)
|
||||
: null}
|
||||
</Text>
|
||||
{state?.filters?.length ? (
|
||||
<Link onClick={() => setAllFilters([])}>
|
||||
<HStack>
|
||||
<FiX display="inline-block" />
|
||||
<Text>
|
||||
{intl.formatMessage(
|
||||
{ id: "tables.clear-all-filters" },
|
||||
{ count: state.filters.length },
|
||||
)}
|
||||
</Text>
|
||||
</HStack>
|
||||
</Link>
|
||||
) : null}
|
||||
</VStack>
|
||||
<nav>
|
||||
<ButtonGroup size="sm" isAttached>
|
||||
<IconButton
|
||||
aria-label={intl.formatMessage({
|
||||
id: "tables.pagination-previous",
|
||||
})}
|
||||
size="sm"
|
||||
icon={<FiChevronsLeft />}
|
||||
isDisabled={!canPreviousPage}
|
||||
onClick={() => gotoPage(0)}
|
||||
/>
|
||||
<IconButton
|
||||
aria-label={intl.formatMessage({
|
||||
id: "tables.pagination-previous",
|
||||
})}
|
||||
size="sm"
|
||||
icon={<FiChevronLeft />}
|
||||
isDisabled={!canPreviousPage}
|
||||
onClick={() => previousPage()}
|
||||
/>
|
||||
<Select
|
||||
size="sm"
|
||||
variant="filled"
|
||||
borderRadius={0}
|
||||
defaultValue={currentPage}
|
||||
disabled={!canPreviousPage && !canNextPage}
|
||||
aria-label={intl.formatMessage({
|
||||
id: "tables.pagination-select",
|
||||
})}>
|
||||
{getPageList()}
|
||||
</Select>
|
||||
<IconButton
|
||||
aria-label={intl.formatMessage({
|
||||
id: "tables.pagination-next",
|
||||
})}
|
||||
size="sm"
|
||||
icon={<FiChevronRight />}
|
||||
isDisabled={!canNextPage}
|
||||
onClick={() => nextPage()}
|
||||
/>
|
||||
<IconButton
|
||||
aria-label={intl.formatMessage({
|
||||
id: "tables.pagination-next",
|
||||
})}
|
||||
size="sm"
|
||||
icon={<FiChevronsRight />}
|
||||
isDisabled={!canNextPage}
|
||||
onClick={() => gotoPage(pageCount - 1)}
|
||||
/>
|
||||
</ButtonGroup>
|
||||
</nav>
|
||||
</HStack>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export { TableLayout };
|
142
frontend/src/components/Table/TextFilter.tsx
Normal file
142
frontend/src/components/Table/TextFilter.tsx
Normal file
@ -0,0 +1,142 @@
|
||||
import {
|
||||
Popover,
|
||||
PopoverTrigger,
|
||||
PopoverContent,
|
||||
PopoverArrow,
|
||||
IconButton,
|
||||
FormControl,
|
||||
FormErrorMessage,
|
||||
Input,
|
||||
Stack,
|
||||
ButtonGroup,
|
||||
Button,
|
||||
useDisclosure,
|
||||
Select,
|
||||
} from "@chakra-ui/react";
|
||||
import { PrettyButton } from "components";
|
||||
import { Formik, Form, Field } from "formik";
|
||||
import { intl } from "locale";
|
||||
import { validateString } from "modules/Validations";
|
||||
import FocusLock from "react-focus-lock";
|
||||
import { FiFilter } from "react-icons/fi";
|
||||
|
||||
function TextFilter({ column: { filterValue, setFilter } }: any) {
|
||||
const { onOpen, onClose, isOpen } = useDisclosure();
|
||||
|
||||
const onSubmit = (values: any, { setSubmitting }: any) => {
|
||||
setFilter(values);
|
||||
setSubmitting(false);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const clearFilter = () => {
|
||||
setFilter(undefined);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const isFiltered = (): boolean => {
|
||||
return !(typeof filterValue === "undefined" || filterValue === "");
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover
|
||||
isOpen={isOpen}
|
||||
onOpen={onOpen}
|
||||
onClose={onClose}
|
||||
placement="right">
|
||||
<PopoverTrigger>
|
||||
<IconButton
|
||||
variant="unstyled"
|
||||
size="sm"
|
||||
color={isFiltered() ? "orange.400" : ""}
|
||||
icon={<FiFilter />}
|
||||
aria-label="Filter"
|
||||
/>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent p={5}>
|
||||
<FocusLock returnFocus persistentFocus={false}>
|
||||
<PopoverArrow />
|
||||
<Formik
|
||||
initialValues={
|
||||
{
|
||||
modifier: filterValue?.modifier || "contains",
|
||||
value: filterValue?.value,
|
||||
} as any
|
||||
}
|
||||
onSubmit={onSubmit}>
|
||||
{({ isSubmitting }) => (
|
||||
<Form>
|
||||
<Stack spacing={4}>
|
||||
<Field name="modifier">
|
||||
{({ field, form }: any) => (
|
||||
<FormControl
|
||||
isRequired
|
||||
isInvalid={
|
||||
form.errors.modifier && form.touched.modifier
|
||||
}>
|
||||
<Select
|
||||
{...field}
|
||||
size="sm"
|
||||
id="modifier"
|
||||
defaultValue="contains">
|
||||
<option value="contains">
|
||||
{intl.formatMessage({ id: "filter.contains" })}
|
||||
</option>
|
||||
<option value="equals">
|
||||
{intl.formatMessage({ id: "filter.exactly" })}
|
||||
</option>
|
||||
<option value="starts">
|
||||
{intl.formatMessage({ id: "filter.starts" })}
|
||||
</option>
|
||||
<option value="ends">
|
||||
{intl.formatMessage({ id: "filter.ends" })}
|
||||
</option>
|
||||
</Select>
|
||||
<FormErrorMessage>{form.errors.name}</FormErrorMessage>
|
||||
</FormControl>
|
||||
)}
|
||||
</Field>
|
||||
<Field name="value" validate={validateString(1, 50)}>
|
||||
{({ field, form }: any) => (
|
||||
<FormControl
|
||||
isRequired
|
||||
isInvalid={form.errors.value && form.touched.value}>
|
||||
<Input
|
||||
{...field}
|
||||
size="sm"
|
||||
placeholder={intl.formatMessage({
|
||||
id: "filter.placeholder",
|
||||
})}
|
||||
autoComplete="off"
|
||||
/>
|
||||
<FormErrorMessage>{form.errors.value}</FormErrorMessage>
|
||||
</FormControl>
|
||||
)}
|
||||
</Field>
|
||||
<ButtonGroup d="flex" justifyContent="flex-end">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={clearFilter}
|
||||
isLoading={isSubmitting}>
|
||||
{intl.formatMessage({
|
||||
id: "filter.clear",
|
||||
})}
|
||||
</Button>
|
||||
<PrettyButton size="sm" isLoading={isSubmitting}>
|
||||
{intl.formatMessage({
|
||||
id: "filter.apply",
|
||||
})}
|
||||
</PrettyButton>
|
||||
</ButtonGroup>
|
||||
</Stack>
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
</FocusLock>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
export { TextFilter };
|
5
frontend/src/components/Table/index.ts
Normal file
5
frontend/src/components/Table/index.ts
Normal file
@ -0,0 +1,5 @@
|
||||
export * from "./Formatters";
|
||||
export * from "./RowActionsMenu";
|
||||
export * from "./TableHelpers";
|
||||
export * from "./TableLayout";
|
||||
export * from "./TextFilter";
|
130
frontend/src/components/Table/react-table-config.d.ts
vendored
Normal file
130
frontend/src/components/Table/react-table-config.d.ts
vendored
Normal file
@ -0,0 +1,130 @@
|
||||
// See: https://github.com/DefinitelyTyped/DefinitelyTyped/tree/master/types/react-table#configuration-using-declaration-merging
|
||||
import {
|
||||
UseColumnOrderInstanceProps,
|
||||
UseColumnOrderState,
|
||||
UseExpandedHooks,
|
||||
UseExpandedInstanceProps,
|
||||
UseExpandedOptions,
|
||||
UseExpandedRowProps,
|
||||
UseExpandedState,
|
||||
UseFiltersColumnOptions,
|
||||
UseFiltersColumnProps,
|
||||
UseFiltersInstanceProps,
|
||||
UseFiltersOptions,
|
||||
UseFiltersState,
|
||||
UseGlobalFiltersColumnOptions,
|
||||
UseGlobalFiltersInstanceProps,
|
||||
UseGlobalFiltersOptions,
|
||||
UseGlobalFiltersState,
|
||||
UseGroupByCellProps,
|
||||
UseGroupByColumnOptions,
|
||||
UseGroupByColumnProps,
|
||||
UseGroupByHooks,
|
||||
UseGroupByInstanceProps,
|
||||
UseGroupByOptions,
|
||||
UseGroupByRowProps,
|
||||
UseGroupByState,
|
||||
UsePaginationInstanceProps,
|
||||
UsePaginationOptions,
|
||||
UsePaginationState,
|
||||
UseResizeColumnsColumnOptions,
|
||||
UseResizeColumnsColumnProps,
|
||||
UseResizeColumnsOptions,
|
||||
UseResizeColumnsState,
|
||||
UseRowSelectHooks,
|
||||
UseRowSelectInstanceProps,
|
||||
UseRowSelectOptions,
|
||||
UseRowSelectRowProps,
|
||||
UseRowSelectState,
|
||||
UseRowStateCellProps,
|
||||
UseRowStateInstanceProps,
|
||||
UseRowStateOptions,
|
||||
UseRowStateRowProps,
|
||||
UseRowStateState,
|
||||
UseSortByColumnOptions,
|
||||
UseSortByColumnProps,
|
||||
UseSortByHooks,
|
||||
UseSortByInstanceProps,
|
||||
UseSortByOptions,
|
||||
UseSortByState,
|
||||
} from "react-table";
|
||||
|
||||
declare module "react-table" {
|
||||
// take this file as-is, or comment out the sections that don't apply to your plugin configuration
|
||||
|
||||
export interface TableOptions<
|
||||
D extends Record<string, unknown>,
|
||||
> extends UseExpandedOptions<D>,
|
||||
UseFiltersOptions<D>,
|
||||
UseGlobalFiltersOptions<D>,
|
||||
UseGroupByOptions<D>,
|
||||
UsePaginationOptions<D>,
|
||||
UseResizeColumnsOptions<D>,
|
||||
UseRowSelectOptions<D>,
|
||||
UseRowStateOptions<D>,
|
||||
UseSortByOptions<D>,
|
||||
// note that having Record here allows you to add anything to the options, this matches the spirit of the
|
||||
// underlying js library, but might be cleaner if it's replaced by a more specific type that matches your
|
||||
// feature set, this is a safe default.
|
||||
Record<string, any> {}
|
||||
|
||||
export interface Hooks<
|
||||
D extends Record<string, unknown> = Record<string, unknown>,
|
||||
> extends UseExpandedHooks<D>,
|
||||
UseGroupByHooks<D>,
|
||||
UseRowSelectHooks<D>,
|
||||
UseSortByHooks<D> {}
|
||||
|
||||
export interface TableInstance<
|
||||
D extends Record<string, unknown> = Record<string, unknown>,
|
||||
> extends UseColumnOrderInstanceProps<D>,
|
||||
UseExpandedInstanceProps<D>,
|
||||
UseFiltersInstanceProps<D>,
|
||||
UseGlobalFiltersInstanceProps<D>,
|
||||
UseGroupByInstanceProps<D>,
|
||||
UsePaginationInstanceProps<D>,
|
||||
UseRowSelectInstanceProps<D>,
|
||||
UseRowStateInstanceProps<D>,
|
||||
UseSortByInstanceProps<D> {}
|
||||
|
||||
export interface TableState<
|
||||
D extends Record<string, unknown> = Record<string, unknown>,
|
||||
> extends UseColumnOrderState<D>,
|
||||
UseExpandedState<D>,
|
||||
UseFiltersState<D>,
|
||||
UseGlobalFiltersState<D>,
|
||||
UseGroupByState<D>,
|
||||
UsePaginationState<D>,
|
||||
UseResizeColumnsState<D>,
|
||||
UseRowSelectState<D>,
|
||||
UseRowStateState<D>,
|
||||
UseSortByState<D> {}
|
||||
|
||||
export interface ColumnInterface<
|
||||
D extends Record<string, unknown> = Record<string, unknown>,
|
||||
> extends UseFiltersColumnOptions<D>,
|
||||
UseGlobalFiltersColumnOptions<D>,
|
||||
UseGroupByColumnOptions<D>,
|
||||
UseResizeColumnsColumnOptions<D>,
|
||||
UseSortByColumnOptions<D> {}
|
||||
|
||||
export interface ColumnInstance<
|
||||
D extends Record<string, unknown> = Record<string, unknown>,
|
||||
> extends UseFiltersColumnProps<D>,
|
||||
UseGroupByColumnProps<D>,
|
||||
UseResizeColumnsColumnProps<D>,
|
||||
UseSortByColumnProps<D> {}
|
||||
|
||||
export interface Cell<
|
||||
D extends Record<string, unknown> = Record<string, unknown>,
|
||||
// V = any,
|
||||
> extends UseGroupByCellProps<D>,
|
||||
UseRowStateCellProps<D> {}
|
||||
|
||||
export interface Row<
|
||||
D extends Record<string, unknown> = Record<string, unknown>,
|
||||
> extends UseExpandedRowProps<D>,
|
||||
UseGroupByRowProps<D>,
|
||||
UseRowSelectRowProps<D>,
|
||||
UseRowStateRowProps<D> {}
|
||||
}
|
34
frontend/src/components/ThemeSwitcher.tsx
Normal file
34
frontend/src/components/ThemeSwitcher.tsx
Normal file
@ -0,0 +1,34 @@
|
||||
import {
|
||||
Icon,
|
||||
IconButton,
|
||||
IconButtonProps,
|
||||
useColorMode,
|
||||
} from "@chakra-ui/react";
|
||||
import { intl } from "locale";
|
||||
import { FiSun, FiMoon } from "react-icons/fi";
|
||||
|
||||
interface ThemeSwitcherProps {
|
||||
background?: "normal" | "transparent";
|
||||
}
|
||||
function ThemeSwitcher({ background }: ThemeSwitcherProps) {
|
||||
const { colorMode, toggleColorMode } = useColorMode();
|
||||
const additionalProps: Partial<IconButtonProps> = {};
|
||||
if (background === "transparent") {
|
||||
additionalProps["backgroundColor"] = "transparent";
|
||||
}
|
||||
|
||||
return (
|
||||
<IconButton
|
||||
onClick={toggleColorMode}
|
||||
{...additionalProps}
|
||||
aria-label={
|
||||
colorMode === "light"
|
||||
? intl.formatMessage({ id: "theme.to-dark" })
|
||||
: intl.formatMessage({ id: "theme.to-light" })
|
||||
}
|
||||
icon={<Icon as={colorMode === "light" ? FiMoon : FiSun} />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { ThemeSwitcher };
|
38
frontend/src/components/Unhealthy.tsx
Normal file
38
frontend/src/components/Unhealthy.tsx
Normal file
@ -0,0 +1,38 @@
|
||||
import { Box, Flex, Heading, Text, Stack } from "@chakra-ui/react";
|
||||
import { LocalePicker, ThemeSwitcher } from "components";
|
||||
import { intl } from "locale";
|
||||
import { FaTimes } from "react-icons/fa";
|
||||
|
||||
function Unhealthy() {
|
||||
return (
|
||||
<>
|
||||
<Stack h={10} m={4} justify="end" direction="row">
|
||||
<ThemeSwitcher />
|
||||
<LocalePicker className="text-right" />
|
||||
</Stack>
|
||||
<Box textAlign="center" py={10} px={6}>
|
||||
<Box display="inline-block">
|
||||
<Flex
|
||||
flexDirection="column"
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
bg="red.500"
|
||||
rounded="50px"
|
||||
w="55px"
|
||||
h="55px"
|
||||
textAlign="center">
|
||||
<FaTimes size="30px" color="white" />
|
||||
</Flex>
|
||||
</Box>
|
||||
<Heading as="h2" size="xl" mt={6} mb={2}>
|
||||
{intl.formatMessage({ id: "unhealthy.title" })}
|
||||
</Heading>
|
||||
<Text color="gray.500">
|
||||
{intl.formatMessage({ id: "unhealthy.body" })}
|
||||
</Text>
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export { Unhealthy };
|
15
frontend/src/components/index.ts
Normal file
15
frontend/src/components/index.ts
Normal file
@ -0,0 +1,15 @@
|
||||
export * from "./EmptyList";
|
||||
export * from "./Flag";
|
||||
export * from "./Footer";
|
||||
export * from "./HelpDrawer";
|
||||
export * from "./Loader";
|
||||
export * from "./Loading";
|
||||
export * from "./LocalePicker";
|
||||
export * from "./Navigation";
|
||||
export * from "./Permissions";
|
||||
export * from "./PrettyButton";
|
||||
export * from "./SiteWrapper";
|
||||
export * from "./SpinnerPage";
|
||||
export * from "./Table";
|
||||
export * from "./ThemeSwitcher";
|
||||
export * from "./Unhealthy";
|
Reference in New Issue
Block a user