diff --git a/frontend/.env.development b/frontend/.env.development index fe9267b2..02f3dd18 100644 --- a/frontend/.env.development +++ b/frontend/.env.development @@ -1,2 +1,4 @@ PORT=9000 IMAGE_INLINE_SIZE_LIMIT=20000 +REACT_APP_VERSION=development +REACT_APP_COMMIT=abcdef1 \ No newline at end of file diff --git a/frontend/src/components/Alert/Alert.tsx b/frontend/src/components/Alert/Alert.tsx deleted file mode 100644 index bc4c608e..00000000 --- a/frontend/src/components/Alert/Alert.tsx +++ /dev/null @@ -1,130 +0,0 @@ -import React, { ReactNode, useState } from "react"; - -import cn from "classnames"; - -import { AlertLink } from "./AlertLink"; - -export interface AlertProps { - /** - * Child elements within - */ - children?: ReactNode; - /** - * Additional Class - */ - className?: string; - /** - * The type of this Alert, changes it's color - */ - type?: "info" | "success" | "warning" | "danger"; - /** - * Alert Title - */ - title?: string; - /** - * An Icon to be displayed on the right hand side of the Alert - */ - icon?: ReactNode; - /** - * Display an Avatar on the left hand side of this Alert - */ - avatar?: ReactNode; - /** - * - */ - important?: boolean; - /** - * Adds an 'X' to the right side of the Alert that dismisses the Alert - */ - dismissable?: boolean; - /** - * Event to call after dissmissing - */ - onDismissClick?: React.MouseEventHandler; -} -export const Alert: React.FC = ({ - children, - className, - type = "info", - title, - icon, - avatar, - important = false, - dismissable = false, - onDismissClick, -}) => { - const [dismissed, setDismissed] = useState(false); - - const classes = { - "alert-dismissible": dismissable, - "alert-important": important, - }; - - const handleDismissed = ( - e: React.MouseEvent, - ) => { - setDismissed(true); - onDismissClick && onDismissClick(e); - }; - - const wrappedTitle = title ?

{title}

: null; - const wrappedChildren = - children && !important ? ( -
{children}
- ) : ( - children - ); - - const wrapIfIcon = (): ReactNode => { - if (avatar) { - return ( -
-
- {avatar} -
-
{wrappedChildren}
-
- ); - } - if (icon) { - return ( -
-
- {icon} -
-
- {wrappedTitle} - {wrappedChildren} -
-
- ); - } - return ( - <> - {wrappedTitle} - {wrappedChildren} - - ); - }; - - if (!dismissed) { - return ( -
- {wrapIfIcon()} - {dismissable ? ( -
- ); - } - return null; -}; - -Alert.Link = AlertLink; diff --git a/frontend/src/components/Alert/AlertLink.tsx b/frontend/src/components/Alert/AlertLink.tsx deleted file mode 100644 index cb8df929..00000000 --- a/frontend/src/components/Alert/AlertLink.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import React, { ReactNode } from "react"; - -import cn from "classnames"; - -export interface AlertLinkProps { - /** - * Child elements within - */ - children?: ReactNode; - /** - * Additional Class - */ - className?: string; - /** - * Href - */ - href?: string; - /** - * onClick handler - */ - onClick?: React.MouseEventHandler; -} -export const AlertLink: React.FC = ({ - children, - className, - href, - onClick, -}) => { - return ( - - {children} - - ); -}; diff --git a/frontend/src/components/Alert/index.ts b/frontend/src/components/Alert/index.ts deleted file mode 100644 index b8e17a03..00000000 --- a/frontend/src/components/Alert/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./Alert"; diff --git a/frontend/src/components/Footer.tsx b/frontend/src/components/Footer.tsx index e949512a..dc7b0b00 100644 --- a/frontend/src/components/Footer.tsx +++ b/frontend/src/components/Footer.tsx @@ -1,80 +1,77 @@ import React from "react"; -import { useHealthState } from "context"; +import { + Box, + Container, + Stack, + Text, + useColorModeValue, +} from "@chakra-ui/react"; import { intl } from "locale"; function Footer() { - const { health } = useHealthState(); - return ( - + + + + {intl.formatMessage( + { + id: "footer.copyright", + defaultMessage: "Copyright © {year} jc21.com", + }, + { year: new Date().getFullYear() }, + )} + + + + {intl.formatMessage({ + id: "footer.userguide", + defaultMessage: "User Guide", + })} + + + {intl.formatMessage({ + id: "footer.changelog", + defaultMessage: "Change Log", + })} + + + {intl.formatMessage({ + id: "footer.github", + defaultMessage: "Github", + })} + + + v{process.env.REACT_APP_VERSION} {String.fromCharCode(183)}{" "} + {process.env.REACT_APP_COMMIT} + + + + ); } diff --git a/frontend/src/components/Nav/Nav.tsx b/frontend/src/components/Nav/Nav.tsx new file mode 100644 index 00000000..a1f1fdb7 --- /dev/null +++ b/frontend/src/components/Nav/Nav.tsx @@ -0,0 +1,99 @@ +import React from "react"; + +import { + Box, + Flex, + Avatar, + HStack, + IconButton, + Button, + Menu, + MenuButton, + MenuList, + MenuItem, + MenuDivider, + useDisclosure, + useColorModeValue, + Stack, +} from "@chakra-ui/react"; +import { LocalePicker, ThemeSwitcher } from "components"; +import { FaBars, FaTimes } from "react-icons/fa"; + +import logo from "../../img/logo-256.png"; +import { NavLink } from "./NavLink"; + +const Links = ["Dashboard", "Projects", "Team"]; + +export const Nav = () => { + const { isOpen, onOpen, onClose } = useDisclosure(); + + return ( + <> + + + : } + aria-label={"Open Menu"} + display={{ md: "none" }} + onClick={isOpen ? onClose : onOpen} + /> + + + Logo + + + {Links.map((link) => ( + {link} + ))} + + + + + + + + + + + + + Link 1 + Link 2 + + Link 3 + + + + + + {isOpen ? ( + + + {Links.map((link) => ( + {link} + ))} + + + ) : null} + + + Main Content Here + + ); +}; + +Nav.Link = NavLink; diff --git a/frontend/src/components/Nav/NavLink.tsx b/frontend/src/components/Nav/NavLink.tsx new file mode 100644 index 00000000..4ed8bd70 --- /dev/null +++ b/frontend/src/components/Nav/NavLink.tsx @@ -0,0 +1,25 @@ +import React, { ReactNode } from "react"; + +import { Link, useColorModeValue } from "@chakra-ui/react"; + +export interface NavLinkProps { + /** + * Child elements within + */ + children?: ReactNode; +} +export const NavLink: React.FC = ({ children }) => { + return ( + + {children} + + ); +}; diff --git a/frontend/src/components/Nav/index.ts b/frontend/src/components/Nav/index.ts new file mode 100644 index 00000000..48956118 --- /dev/null +++ b/frontend/src/components/Nav/index.ts @@ -0,0 +1 @@ +export * from "./Nav"; diff --git a/frontend/src/components/NavMenu.tsx b/frontend/src/components/NavMenu.tsx deleted file mode 100644 index 4f4f1989..00000000 --- a/frontend/src/components/NavMenu.tsx +++ /dev/null @@ -1,104 +0,0 @@ -import React from "react"; - -import { Dropdown, Navigation } from "components"; -import { intl } from "locale"; -import { - Book, - DeviceDesktop, - Home, - Lock, - Settings, - Shield, - Users, -} from "tabler-icons-react"; - -const NavMenu: React.FC<{ openOnMobile: boolean }> = ({ openOnMobile }) => { - return ( - , - to: "/", - }, - { - title: intl.formatMessage({ - id: "hosts.title", - defaultMessage: "Hosts", - }), - icon: , - to: "/hosts", - }, - { - title: intl.formatMessage({ - id: "accesslists.title", - defaultMessage: "Access Lists", - }), - icon: , - to: "/access-lists", - }, - { - title: "SSL", - icon: , - dropdownItems: [ - - - {intl.formatMessage({ - id: "certificates.title", - defaultMessage: "Certificates", - })} - - , - - - {intl.formatMessage({ - id: "cert_authorities.title", - defaultMessage: "Certificate Authorities", - })} - - , - ], - }, - { - title: intl.formatMessage({ - id: "auditlog.title", - defaultMessage: "Audit Log", - }), - icon: , - to: "/audit-log", - }, - { - title: intl.formatMessage({ - id: "users.title", - defaultMessage: "Users", - }), - icon: , - to: "/users", - }, - { - title: intl.formatMessage({ - id: "settings.title", - defaultMessage: "Settings", - }), - icon: , - to: "/settings", - }, - ]} - /> - ); -}; - -export { NavMenu }; diff --git a/frontend/src/components/Navigation/Navigation.tsx b/frontend/src/components/Navigation/Navigation.tsx deleted file mode 100644 index 3d0d69b9..00000000 --- a/frontend/src/components/Navigation/Navigation.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import { ReactNode } from "react"; - -import { NavigationHeader } from "./NavigationHeader"; -import { NavigationMenu } from "./NavigationMenu"; -import { NavigationMenuItem } from "./NavigationMenuItem"; - -export interface NavigationProps { - /** - * Child elements within - */ - children?: ReactNode; -} -export const Navigation = ({ children }: NavigationProps) => { - return children; -}; - -Navigation.Header = NavigationHeader; -Navigation.Menu = NavigationMenu; -Navigation.MenuItem = NavigationMenuItem; diff --git a/frontend/src/components/Navigation/NavigationHeader.tsx b/frontend/src/components/Navigation/NavigationHeader.tsx deleted file mode 100644 index e2ca21a1..00000000 --- a/frontend/src/components/Navigation/NavigationHeader.tsx +++ /dev/null @@ -1,211 +0,0 @@ -import React, { ReactNode, useState, useRef, useEffect } from "react"; - -import cn from "classnames"; -import { Bell } from "tabler-icons-react"; - -import { NavMenu } from ".."; -import { Badge } from "../Badge"; -import { ButtonList } from "../ButtonList"; -import { Dropdown } from "../Dropdown"; -import { NavigationMenu } from "./NavigationMenu"; -import { NavigationMenuItemProps } from "./NavigationMenuItem"; -export interface NavigationHeaderProps { - /** - * Additional Class - */ - className?: string; - /** - * Logo and/or Text elements to show on the left brand side of the header - */ - brandContent?: ReactNode; - /** - * Color theme for the nav bar - */ - theme?: "transparent" | "light" | "dark"; - /** - * Buttons to show in the header - */ - buttons?: ReactNode[]; - /** - * Notifications Content - */ - notifications?: ReactNode; - /** - * Has unread notifications, shows red dot - */ - hasUnreadNotifications?: boolean; - /** - * Avatar Object - */ - avatar?: ReactNode; - /** - * Profile name to show next to avatar - */ - profileName?: string; - /** - * Profile text to show beneath profileName - */ - profileSubName?: string; - /** - * Profile dropdown menu items - */ - profileItems?: ReactNode[]; - /** - * Applies dark theme to Notifications and Profile dropdowns - */ - darkDropdowns?: boolean; - /** - * Navigation Menu within this Header - */ - menuItems?: NavigationMenuItemProps[]; -} -export const NavigationHeader: React.FC = ({ - className, - theme = "transparent", - brandContent, - buttons, - notifications, - hasUnreadNotifications, - avatar, - profileName, - profileSubName, - profileItems, - darkDropdowns, - menuItems, -}) => { - const [notificationsShown, setNotificationsShown] = useState(false); - const [profileShown, setProfileShown] = useState(false); - const [mobileNavShown, setMobileNavShown] = useState(false); - const profileRef = useRef(null); - const notificationsRef = useRef(null); - - const toggleMobileNavShown = () => - setMobileNavShown((prevState) => { - return !prevState; - }); - - const handleClickOutside = (event: any) => { - if ( - profileRef.current && - // @ts-expect-error ts-migrate(2531) FIXME: Object is possibly 'null'. - !profileRef.current.contains(event.target) - ) { - setProfileShown(false); - } - if ( - notificationsRef.current && - // @ts-expect-error ts-migrate(2531) FIXME: Object is possibly 'null'. - !notificationsRef.current.contains(event.target) - ) { - setNotificationsShown(false); - } - }; - - useEffect(() => { - document.addEventListener("mousedown", handleClickOutside); - return () => document.removeEventListener("mousedown", handleClickOutside); - }, []); - - return ( - <> -
-
- -

- {brandContent} -

-
- {buttons ? ( -
- {buttons} -
- ) : null} - {notifications ? ( -
- - -
-
{notifications}
-
-
-
- ) : null} -
- - {profileItems ? ( - - {profileItems} - - ) : null} -
-
- {menuItems ? ( - - ) : null} -
-
- - - ); -}; diff --git a/frontend/src/components/Navigation/NavigationMenu.tsx b/frontend/src/components/Navigation/NavigationMenu.tsx deleted file mode 100644 index b3768ace..00000000 --- a/frontend/src/components/Navigation/NavigationMenu.tsx +++ /dev/null @@ -1,130 +0,0 @@ -import React, { ReactNode, useState, useRef, useEffect } from "react"; - -import cn from "classnames"; -import styled from "styled-components"; - -import { - NavigationMenuItem, - NavigationMenuItemProps, -} from "./NavigationMenuItem"; - -/** - * This menu handles the state of the dropdowns being shown, instead of state - * being handled within the NavigationItem object, because we want the behaviour - * of clicking one menu item with a dropdown to close the already open dropdown - * of another menu item. This can only be done if we handle state one level above - * the items. - */ - -const StyledNavWrapper = styled.div<{ shown: boolean }>` - @media (max-width: 767.98px) { - transition: max-height 300ms ease-in-out; - max-height: ${(p) => (p.shown ? `80vh` : `0`)}; - min-height: ${(p) => (p.shown ? `inherit` : `0`)}; - overflow: hidden; - padding: ${(p) => (p.shown ? `inherit` : `0`)}; - } -`; - -export interface NavigationMenuProps { - /** Additional Class */ - className?: string; - /** Navigation Items */ - items: NavigationMenuItemProps[]; - /** If this menu sits within a Navigation.Header */ - withinHeader?: boolean; - /** Color theme for the nav bar */ - theme?: "transparent" | "light" | "dark"; - /** Search content */ - searchContent?: ReactNode; - /** Navigation is currently hidden on mobile */ - openOnMobile?: boolean; -} -export const NavigationMenu: React.FC = ({ - className, - items, - withinHeader, - theme = "transparent", - searchContent, - openOnMobile = true, -}) => { - const [dropdownShown, setDropdownShown] = useState(0); - const navRef = useRef(null); - - const handleClickOutside = (event: any) => { - if ( - navRef.current && - // @ts-expect-error ts-migrate(2531) FIXME: Object is possibly 'null'. - !navRef.current.contains(event.target) - ) { - setDropdownShown(0); - } - }; - - useEffect(() => { - document.addEventListener("mousedown", handleClickOutside); - return () => document.removeEventListener("mousedown", handleClickOutside); - }, []); - - const itemClicked = ( - e: React.MouseEvent, - item: NavigationMenuItemProps, - idx: number, - ) => { - setDropdownShown(dropdownShown === idx ? 0 : idx); - item.onClick && item.onClick(e); - }; - - const wrapMenu = (el: ReactNode) => { - if (withinHeader) { - return ( -
- -
{el}
-
-
- ); - } - return ( -
- -
- ); - }; - - return wrapMenu( -
    - {items.map((item: any, idx: number) => { - const onClickItem = ( - e: React.MouseEvent, - ) => { - itemClicked(e, item, idx); - }; - return ( - - ); - })} -
, - ); -}; - -NavigationMenu.Item = NavigationMenuItem; diff --git a/frontend/src/components/Navigation/NavigationMenuItem.tsx b/frontend/src/components/Navigation/NavigationMenuItem.tsx deleted file mode 100644 index 9c5b16fd..00000000 --- a/frontend/src/components/Navigation/NavigationMenuItem.tsx +++ /dev/null @@ -1,122 +0,0 @@ -import React, { ReactNode } from "react"; - -import cn from "classnames"; -import { Link, useRouteMatch } from "react-router-dom"; - -import { Dropdown } from "../Dropdown"; - -export interface NavigationMenuItemProps { - /** - * Additional Class - */ - className?: string; - /** - * An Icon to be displayed on the right hand side of the Alert - */ - icon?: ReactNode; - /** - * Title of the Item - */ - title: string; - /** - * Href if this is navigating somewhere - */ - href?: string; - /** - * target property, only used when href is set - */ - target?: string; - /** - * Router Link to if using react-router-dom - */ - to?: string; - /** - * Router Link property if using react-router-dom - */ - activeOnlyWhenExact?: boolean; - /** - * On click handler - */ - onClick?: React.MouseEventHandler; - /** - * Provide dropdown items if this is to be a dropdown menu - */ - dropdownItems?: ReactNode[]; - /** - * State of the dropdown being shown - */ - dropdownShow?: boolean; - /** - * Applies dark theme to dropdown - */ - darkDropdown?: boolean; - /** - * Shows this item as being active - */ - active?: boolean; - /** - * Disables the menu item - */ - disabled?: boolean; - /** - * Badge if you want to show one - */ - badge?: ReactNode; -} -export const NavigationMenuItem: React.FC = ({ - className, - icon, - title, - href, - target, - to, - activeOnlyWhenExact, - onClick, - dropdownItems, - dropdownShow, - darkDropdown, - active, - disabled, - badge, -}) => { - const match = useRouteMatch({ - path: to, - exact: activeOnlyWhenExact, - }); - - return ( -
  • - - {icon && ( - - {icon} - - )} - {title} - {badge} - - {dropdownItems ? ( - - {dropdownItems} - - ) : null} -
  • - ); -}; diff --git a/frontend/src/components/Navigation/index.ts b/frontend/src/components/Navigation/index.ts deleted file mode 100644 index 8953bdfa..00000000 --- a/frontend/src/components/Navigation/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./Navigation"; diff --git a/frontend/src/components/SiteWrapper.tsx b/frontend/src/components/SiteWrapper.tsx index 431db16c..8bc90b57 100644 --- a/frontend/src/components/SiteWrapper.tsx +++ b/frontend/src/components/SiteWrapper.tsx @@ -1,10 +1,10 @@ import React, { ReactNode } from "react"; import { Footer } from "components"; -import { Avatar, Dropdown, Navigation } from "components"; -import { LocalePicker } from "components"; -import { useAuthState, useUserState } from "context"; -import { intl } from "locale"; +import { Nav } from "components"; +// import { LocalePicker } from "components"; +// import { useAuthState, useUserState } from "context"; +// import { intl } from "locale"; import styled from "styled-components"; const StyledSiteContainer = styled.div` @@ -29,13 +29,14 @@ interface Props { children?: ReactNode; } function SiteWrapper({ children }: Props) { - const user = useUserState(); - const { logout } = useAuthState(); + // const user = useUserState(); + // const { logout } = useAuthState(); return ( - + {/* theme="light" brandContent={ , ]} /> + */}
    {children} diff --git a/frontend/src/components/ThemeSwitcher.tsx b/frontend/src/components/ThemeSwitcher.tsx index 902740f5..e876b4f1 100644 --- a/frontend/src/components/ThemeSwitcher.tsx +++ b/frontend/src/components/ThemeSwitcher.tsx @@ -3,11 +3,13 @@ import React from "react"; import { Button, Icon, useColorMode } from "@chakra-ui/react"; import { FiSun, FiMoon } from "react-icons/fi"; -export const ThemeSwitcher: React.FC = () => { +function ThemeSwitcher() { const { colorMode, toggleColorMode } = useColorMode(); return ( ); -}; +} + +export { ThemeSwitcher }; diff --git a/frontend/src/components/Unhealthy.tsx b/frontend/src/components/Unhealthy.tsx index 68ec561b..5c8e86d5 100644 --- a/frontend/src/components/Unhealthy.tsx +++ b/frontend/src/components/Unhealthy.tsx @@ -1,44 +1,45 @@ import React from "react"; -import { Alert } from "components"; -import styled from "styled-components"; - -const Root = styled.div` - padding: 20vh 10vw 0 10vw; - - && .ant-alert-warning { - background-color: #2a2a2a; - border: 2px solid #2ab1a4; - color: #eee; - } - - && .ant-alert-message { - color: #fff; - font-size: 6vh; - } - - && .ant-alert-description { - font-size: 4vh; - line-height: 5vh; - } - - && .ant-alert-with-description { - padding-left: 23vh; - } - - && .ant-alert-with-description .ant-alert-icon { - font-size: 15vh; - } -`; +import { Box, Flex, Heading, Text, Stack } from "@chakra-ui/react"; +import { LocalePicker } from "components"; +import { intl } from "locale"; +import { FaTimes } from "react-icons/fa"; function Unhealthy() { return ( - - - Nginx Proxy Manager is unhealthy. We'll continue to - check the health and hope to be back up and running soon! - - + <> + + + + + + + + + + + {intl.formatMessage({ + id: "unhealthy.title", + defaultMessage: "Nginx Proxy Manager is unhealthy", + })} + + + {intl.formatMessage({ + id: "unhealthy.body", + defaultMessage: + "We'll continue to check the health and hope to be back up and running soon!", + })} + + + ); } diff --git a/frontend/src/components/index.ts b/frontend/src/components/index.ts index 0afd41e0..6aea24e1 100644 --- a/frontend/src/components/index.ts +++ b/frontend/src/components/index.ts @@ -1,4 +1,3 @@ -export * from "./Alert"; export * from "./Avatar"; export * from "./AvatarList"; export * from "./Badge"; @@ -10,11 +9,11 @@ export * from "./Footer"; export * from "./Loader"; export * from "./Loading"; export * from "./LocalePicker"; -export * from "./Navigation"; -export * from "./NavMenu"; +export * from "./Nav"; export * from "./Router"; export * from "./SinglePage"; export * from "./SiteWrapper"; export * from "./SuspenseLoader"; export * from "./Table"; +export * from "./ThemeSwitcher"; export * from "./Unhealthy"; diff --git a/frontend/src/locale/src/en.json b/frontend/src/locale/src/en.json index 89d1be2e..8ba74df5 100644 --- a/frontend/src/locale/src/en.json +++ b/frontend/src/locale/src/en.json @@ -80,6 +80,12 @@ "setup-required": { "defaultMessage": "Setup Required" }, + "unhealthy.title": { + "defaultMessage": "Nginx Proxy Manager is unhealthy" + }, + "unhealthy.body": { + "defaultMessage": "We'll continue to check the health and hope to be back up and running soon!" + }, "user.email": { "defaultMessage": "Email" }, diff --git a/frontend/src/pages/Login/index.tsx b/frontend/src/pages/Login/index.tsx index 1b242aec..97353a9b 100644 --- a/frontend/src/pages/Login/index.tsx +++ b/frontend/src/pages/Login/index.tsx @@ -12,11 +12,10 @@ import { useToast, Link, } from "@chakra-ui/react"; -import { LocalePicker } from "components"; +import { LocalePicker, ThemeSwitcher } from "components"; import { useAuthState } from "context"; import { intl } from "locale"; -import { ThemeSwitcher } from "../../components/ThemeSwitcher"; import logo from "../../img/logo-256.png"; function Login() { diff --git a/frontend/src/pages/Setup/index.tsx b/frontend/src/pages/Setup/index.tsx index 544b2670..95f3ea20 100644 --- a/frontend/src/pages/Setup/index.tsx +++ b/frontend/src/pages/Setup/index.tsx @@ -11,11 +11,10 @@ import { useToast, } from "@chakra-ui/react"; import { createUser } from "api/npm"; -import { LocalePicker } from "components"; +import { LocalePicker, ThemeSwitcher } from "components"; import { useAuthState, useHealthState } from "context"; import { intl } from "locale"; -import { ThemeSwitcher } from "../../components/ThemeSwitcher"; import logo from "../../img/logo-256.png"; function Setup() { diff --git a/scripts/ci/build-frontend b/scripts/ci/build-frontend index ecef8e25..9b9f18c3 100755 --- a/scripts/ci/build-frontend +++ b/scripts/ci/build-frontend @@ -7,7 +7,13 @@ DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" . "$DIR/../.common.sh" docker_cmd() { - docker run --rm -e CI=true -v "$(pwd)/frontend:/app/frontend" -w /app/frontend node:14 ${*} + docker run --rm \ + -e CI=true \ + -e REACT_APP_VERSION=${BUILD_VERSION:-0.0.0} \ + -e REACT_APP_COMMIT=${BUILD_COMMIT:-0000000} \ + -v "$(pwd)/frontend:/app/frontend" \ + -w /app/frontend node:14 \ + ${*} } cd "${DIR}/../.." || exit 1