diff --git a/hyperglass/configuration/main.py b/hyperglass/configuration/main.py index 57104623..c26493be 100644 --- a/hyperglass/configuration/main.py +++ b/hyperglass/configuration/main.py @@ -2,7 +2,6 @@ # Standard Library import os -import copy import json from typing import Dict, List from pathlib import Path @@ -18,6 +17,7 @@ enable_syslog_logging, ) from hyperglass.util import set_app_path, set_cache_env, current_log_level +from hyperglass.defaults import CREDIT, DEFAULT_DETAILS from hyperglass.constants import ( SUPPORTED_QUERY_TYPES, PARSED_RESPONSE_FIELDS, @@ -28,12 +28,6 @@ from hyperglass.models.commands import Commands from hyperglass.models.config.params import Params from hyperglass.models.config.devices import Devices -from hyperglass.configuration.defaults import ( - CREDIT, - DEFAULT_HELP, - DEFAULT_TERMS, - DEFAULT_DETAILS, -) # Local from .markdown import get_markdown @@ -332,17 +326,6 @@ def _build_vrf_help() -> Dict: content_vrf = _build_vrf_help() -content_help_params = copy.copy(content_params) -content_help_params["title"] = params.web.help_menu.title -content_help = get_markdown( - config_path=params.web.help_menu, default=DEFAULT_HELP, params=content_help_params -) - -content_terms_params = copy.copy(content_params) -content_terms_params["title"] = params.web.terms.title -content_terms = get_markdown( - config_path=params.web.terms, default=DEFAULT_TERMS, params=content_terms_params -) content_credit = CREDIT.format(version=__version__) networks = _build_networks() @@ -374,8 +357,6 @@ def _build_vrf_help() -> Dict: "networks": networks, "parsed_data_fields": PARSED_RESPONSE_FIELDS, "content": { - "help_menu": content_help, - "terms": content_terms, "credit": content_credit, "vrf": content_vrf, "greeting": content_greeting, diff --git a/hyperglass/configuration/defaults.py b/hyperglass/defaults.py similarity index 100% rename from hyperglass/configuration/defaults.py rename to hyperglass/defaults.py diff --git a/hyperglass/models/config/web.py b/hyperglass/models/config/web.py index 6bc1310e..53d8627c 100644 --- a/hyperglass/models/config/web.py +++ b/hyperglass/models/config/web.py @@ -1,7 +1,7 @@ """Validate branding configuration variables.""" # Standard Library -from typing import Union, Optional +from typing import Union, Optional, Sequence from pathlib import Path # Third Party @@ -18,6 +18,7 @@ from pydantic.color import Color # Project +from hyperglass.defaults import DEFAULT_HELP, DEFAULT_TERMS from hyperglass.constants import DNS_OVER_HTTPS, FUNC_COLOR_MAP # Local @@ -31,6 +32,7 @@ ColorMode = constr(regex=r"light|dark") DOHProvider = constr(regex="|".join(DNS_OVER_HTTPS.keys())) Title = constr(max_length=32) +Side = constr(regex=r"left|right") class Analytics(HyperglassModel): @@ -64,20 +66,36 @@ class Credit(HyperglassModel): enable: StrictBool = True -class ExternalLink(HyperglassModel): - """Validation model for external link.""" +class Link(HyperglassModel): + """Validation model for generic link.""" - enable: StrictBool = True - title: StrictStr = "PeeringDB" - url: HttpUrl = "https://www.peeringdb.com/asn/{primary_asn}" + title: StrictStr + url: HttpUrl + show_icon: StrictBool = True + side: Side = "left" + order: StrictInt = 0 -class HelpMenu(HyperglassModel): - """Validation model for generic help menu.""" +class Menu(HyperglassModel): + """Validation model for generic menu.""" - enable: StrictBool = True - file: Optional[FilePath] - title: StrictStr = "Help" + title: StrictStr + content: StrictStr + side: Side = "left" + order: StrictInt = 0 + + @validator("content") + def validate_content(cls, value): + """Read content from file if a path is provided.""" + + if len(value) < 260: + path = Path(value) + if path.exists(): + with path.open("r") as f: + return f.read() + else: + return value + return value class Greeting(HyperglassModel): @@ -107,14 +125,6 @@ class Logo(HyperglassModel): height: Optional[Union[StrictInt, Percentage]] -class Terms(HyperglassModel): - """Validation model for terms & conditions.""" - - enable: StrictBool = True - file: Optional[FilePath] - title: StrictStr = "Terms" - - class Text(HyperglassModel): """Validation model for params.branding.text.""" @@ -236,11 +246,15 @@ class Web(HyperglassModel): credit: Credit = Credit() dns_provider: DnsOverHttps = DnsOverHttps() - external_link: ExternalLink = ExternalLink() + links: Sequence[Link] = [ + Link(title="PeeringDB", url="https://www.peeringdb.com/asn/{primary_asn}") + ] + menus: Sequence[Menu] = [ + Menu(title="Terms", content=DEFAULT_TERMS), + Menu(title="Help", content=DEFAULT_HELP), + ] greeting: Greeting = Greeting() - help_menu: HelpMenu = HelpMenu() logo: Logo = Logo() opengraph: OpenGraph = OpenGraph() - terms: Terms = Terms() text: Text = Text() theme: Theme = Theme() diff --git a/hyperglass/ui/components/footer/button.tsx b/hyperglass/ui/components/footer/button.tsx index 96d4126c..a67193d1 100644 --- a/hyperglass/ui/components/footer/button.tsx +++ b/hyperglass/ui/components/footer/button.tsx @@ -1,16 +1,37 @@ +import { useMemo } from 'react'; import { Button, Menu, MenuButton, MenuList } from '@chakra-ui/react'; import { Markdown } from '~/components'; -import { useColorValue, useBreakpointValue } from '~/context'; -import { useOpposingColor } from '~/hooks'; +import { useColorValue, useBreakpointValue, useConfig } from '~/context'; +import { useOpposingColor, useStrf } from '~/hooks'; +import type { IConfig } from '~/types'; import type { TFooterButton } from './types'; +/** + * Filter the configuration object based on values that are strings for formatting. + */ +function getConfigFmt(config: IConfig): Record { + const fmt = {} as Record; + for (const [k, v] of Object.entries(config)) { + if (typeof v === 'string') { + fmt[k] = v; + } + } + return fmt; +} + export const FooterButton: React.FC = (props: TFooterButton) => { const { content, title, side, ...rest } = props; + + const config = useConfig(); + const fmt = useMemo(() => getConfigFmt(config), []); + const fmtContent = useStrf(content, fmt); + const placement = side === 'left' ? 'top' : side === 'right' ? 'top-end' : undefined; const bg = useColorValue('white', 'gray.900'); const color = useOpposingColor(bg); const size = useBreakpointValue({ base: 'xs', lg: 'sm' }); + return ( = (props: TFooterButton) => { boxShadow="2xl" textAlign="left" overflowY="auto" + whiteSpace="normal" mx={{ base: 1, lg: 2 }} maxW={{ base: '100%', lg: '50vw' }} {...rest} > - + ); diff --git a/hyperglass/ui/components/footer/footer.tsx b/hyperglass/ui/components/footer/footer.tsx index 4a839c45..80a28a9c 100644 --- a/hyperglass/ui/components/footer/footer.tsx +++ b/hyperglass/ui/components/footer/footer.tsx @@ -1,27 +1,43 @@ +import { useMemo } from 'react'; import dynamic from 'next/dynamic'; -import { Button, Flex, Link, Icon, HStack, useToken } from '@chakra-ui/react'; +import { Flex, Icon, HStack, useToken } from '@chakra-ui/react'; import { If } from '~/components'; import { useConfig, useMobile, useColorValue, useBreakpointValue } from '~/context'; import { useStrf } from '~/hooks'; import { FooterButton } from './button'; import { ColorModeToggle } from './colorMode'; +import { FooterLink } from './link'; +import { isLink, isMenu } from './types'; + +import type { ButtonProps, LinkProps } from '@chakra-ui/react'; +import type { TLink, TMenu } from '~/types'; const CodeIcon = dynamic(() => import('@meronex/icons/fi').then(i => i.FiCode)); const ExtIcon = dynamic(() => import('@meronex/icons/go').then(i => i.GoLinkExternal)); +function buildItems(links: TLink[], menus: TMenu[]): [(TLink | TMenu)[], (TLink | TMenu)[]] { + const leftLinks = links.filter(link => link.side === 'left'); + const leftMenus = menus.filter(menu => menu.side === 'left'); + const rightLinks = links.filter(link => link.side === 'right'); + const rightMenus = menus.filter(menu => menu.side === 'right'); + + const left = [...leftLinks, ...leftMenus].sort((a, b) => (a.order > b.order ? 1 : -1)); + const right = [...rightLinks, ...rightMenus].sort((a, b) => (a.order > b.order ? 1 : -1)); + return [left, right]; +} + export const Footer: React.FC = () => { const { web, content, primary_asn } = useConfig(); const footerBg = useColorValue('blackAlpha.50', 'whiteAlpha.100'); const footerColor = useColorValue('black', 'white'); - const extUrl = useStrf(web.external_link.url, { primary_asn }) ?? '/'; - const size = useBreakpointValue({ base: useToken('sizes', 4), lg: useToken('sizes', 6) }); - const btnSize = useBreakpointValue({ base: 'xs', lg: 'sm' }); const isMobile = useMobile(); + const [left, right] = useMemo(() => buildItems(web.links, web.menus), []); + return ( { zIndex={1} as="footer" bg={footerBg} + whiteSpace="nowrap" color={footerColor} spacing={{ base: 8, lg: 6 }} + d={{ base: 'inline-block', lg: 'flex' }} + overflowY={{ base: 'auto', lg: 'unset' }} justifyContent={{ base: 'center', lg: 'space-between' }} > - - - - - - - - - + {left.map(item => { + if (isLink(item)) { + const url = useStrf(item.url, { primary_asn }) ?? '/'; + const icon: Partial = {}; + + if (item.show_icon) { + icon.rightIcon = ; + } + return ; + } else if (isMenu(item)) { + return ( + + ); + } + })} {!isMobile && } + {right.map(item => { + if (isLink(item)) { + const url = useStrf(item.url, { primary_asn }) ?? '/'; + const icon: Partial = {}; + + if (item.show_icon) { + icon.rightIcon = ; + } + return ; + } else if (isMenu(item)) { + return ; + } + })} = (props: TFooterLink) => { + const { title } = props; + const btnSize = useBreakpointValue({ base: 'xs', lg: 'sm' }); + return ( + + ); +}; diff --git a/hyperglass/ui/components/footer/types.ts b/hyperglass/ui/components/footer/types.ts index ddd71bd0..8f6143d7 100644 --- a/hyperglass/ui/components/footer/types.ts +++ b/hyperglass/ui/components/footer/types.ts @@ -1,4 +1,5 @@ -import type { ButtonProps, MenuListProps } from '@chakra-ui/react'; +import type { ButtonProps, LinkProps, MenuListProps } from '@chakra-ui/react'; +import type { TLink, TMenu } from '~/types'; type TFooterSide = 'left' | 'right'; @@ -8,8 +9,18 @@ export interface TFooterButton extends Omit { content: string; } +export type TFooterLink = ButtonProps & LinkProps & { title: string }; + export type TFooterItems = 'help' | 'credit' | 'terms'; export interface TColorModeToggle extends ButtonProps { size?: string; } + +export function isLink(item: TLink | TMenu): item is TLink { + return 'url' in item; +} + +export function isMenu(item: TLink | TMenu): item is TMenu { + return 'content' in item; +} diff --git a/hyperglass/ui/types/config.ts b/hyperglass/ui/types/config.ts index f7a36c22..0e7d36d8 100644 --- a/hyperglass/ui/types/config.ts +++ b/hyperglass/ui/types/config.ts @@ -2,6 +2,8 @@ import type { Theme } from './theme'; export type TQueryFields = 'query_type' | 'query_target' | 'query_location' | 'query_vrf'; +type TSide = 'left' | 'right'; + export interface IConfigMessages { no_input: string; acl_denied: string; @@ -62,10 +64,26 @@ export interface TConfigWebLogo { dark_format: string; } +export interface TLink { + title: string; + url: string; + show_icon: boolean; + side: TSide; + order: number; +} + +export interface TMenu { + title: string; + content: string; + side: TSide; + order: number; +} + export interface IConfigWeb { credit: { enable: boolean }; dns_provider: { name: string; url: string }; - external_link: { enable: boolean; title: string; url: string }; + links: TLink[]; + menus: TMenu[]; greeting: TConfigGreeting; help_menu: { enable: boolean; title: string }; logo: TConfigWebLogo; @@ -149,8 +167,6 @@ export interface TQueryContent { } export interface IConfigContent { - help_menu: string; - terms: string; credit: string; greeting: string; vrf: {