mirror of
https://github.com/checktheroads/hyperglass
synced 2024-05-11 05:55:08 +00:00
Closes #140: Genericize footer links and menus and allow multiple definitions
This commit is contained in:
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
# Standard Library
|
# Standard Library
|
||||||
import os
|
import os
|
||||||
import copy
|
|
||||||
import json
|
import json
|
||||||
from typing import Dict, List
|
from typing import Dict, List
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -18,6 +17,7 @@ from hyperglass.log import (
|
|||||||
enable_syslog_logging,
|
enable_syslog_logging,
|
||||||
)
|
)
|
||||||
from hyperglass.util import set_app_path, set_cache_env, current_log_level
|
from hyperglass.util import set_app_path, set_cache_env, current_log_level
|
||||||
|
from hyperglass.defaults import CREDIT, DEFAULT_DETAILS
|
||||||
from hyperglass.constants import (
|
from hyperglass.constants import (
|
||||||
SUPPORTED_QUERY_TYPES,
|
SUPPORTED_QUERY_TYPES,
|
||||||
PARSED_RESPONSE_FIELDS,
|
PARSED_RESPONSE_FIELDS,
|
||||||
@@ -28,12 +28,6 @@ from hyperglass.util.files import check_path
|
|||||||
from hyperglass.models.commands import Commands
|
from hyperglass.models.commands import Commands
|
||||||
from hyperglass.models.config.params import Params
|
from hyperglass.models.config.params import Params
|
||||||
from hyperglass.models.config.devices import Devices
|
from hyperglass.models.config.devices import Devices
|
||||||
from hyperglass.configuration.defaults import (
|
|
||||||
CREDIT,
|
|
||||||
DEFAULT_HELP,
|
|
||||||
DEFAULT_TERMS,
|
|
||||||
DEFAULT_DETAILS,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Local
|
# Local
|
||||||
from .markdown import get_markdown
|
from .markdown import get_markdown
|
||||||
@@ -332,17 +326,6 @@ content_greeting = get_markdown(
|
|||||||
|
|
||||||
content_vrf = _build_vrf_help()
|
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__)
|
content_credit = CREDIT.format(version=__version__)
|
||||||
|
|
||||||
networks = _build_networks()
|
networks = _build_networks()
|
||||||
@@ -374,8 +357,6 @@ _frontend_params.update(
|
|||||||
"networks": networks,
|
"networks": networks,
|
||||||
"parsed_data_fields": PARSED_RESPONSE_FIELDS,
|
"parsed_data_fields": PARSED_RESPONSE_FIELDS,
|
||||||
"content": {
|
"content": {
|
||||||
"help_menu": content_help,
|
|
||||||
"terms": content_terms,
|
|
||||||
"credit": content_credit,
|
"credit": content_credit,
|
||||||
"vrf": content_vrf,
|
"vrf": content_vrf,
|
||||||
"greeting": content_greeting,
|
"greeting": content_greeting,
|
||||||
|
@@ -1,7 +1,7 @@
|
|||||||
"""Validate branding configuration variables."""
|
"""Validate branding configuration variables."""
|
||||||
|
|
||||||
# Standard Library
|
# Standard Library
|
||||||
from typing import Union, Optional
|
from typing import Union, Optional, Sequence
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
# Third Party
|
# Third Party
|
||||||
@@ -18,6 +18,7 @@ from pydantic import (
|
|||||||
from pydantic.color import Color
|
from pydantic.color import Color
|
||||||
|
|
||||||
# Project
|
# Project
|
||||||
|
from hyperglass.defaults import DEFAULT_HELP, DEFAULT_TERMS
|
||||||
from hyperglass.constants import DNS_OVER_HTTPS, FUNC_COLOR_MAP
|
from hyperglass.constants import DNS_OVER_HTTPS, FUNC_COLOR_MAP
|
||||||
|
|
||||||
# Local
|
# Local
|
||||||
@@ -31,6 +32,7 @@ TitleMode = constr(regex=("logo_only|text_only|logo_title|logo_subtitle|all"))
|
|||||||
ColorMode = constr(regex=r"light|dark")
|
ColorMode = constr(regex=r"light|dark")
|
||||||
DOHProvider = constr(regex="|".join(DNS_OVER_HTTPS.keys()))
|
DOHProvider = constr(regex="|".join(DNS_OVER_HTTPS.keys()))
|
||||||
Title = constr(max_length=32)
|
Title = constr(max_length=32)
|
||||||
|
Side = constr(regex=r"left|right")
|
||||||
|
|
||||||
|
|
||||||
class Analytics(HyperglassModel):
|
class Analytics(HyperglassModel):
|
||||||
@@ -64,20 +66,36 @@ class Credit(HyperglassModel):
|
|||||||
enable: StrictBool = True
|
enable: StrictBool = True
|
||||||
|
|
||||||
|
|
||||||
class ExternalLink(HyperglassModel):
|
class Link(HyperglassModel):
|
||||||
"""Validation model for external link."""
|
"""Validation model for generic link."""
|
||||||
|
|
||||||
enable: StrictBool = True
|
title: StrictStr
|
||||||
title: StrictStr = "PeeringDB"
|
url: HttpUrl
|
||||||
url: HttpUrl = "https://www.peeringdb.com/asn/{primary_asn}"
|
show_icon: StrictBool = True
|
||||||
|
side: Side = "left"
|
||||||
|
order: StrictInt = 0
|
||||||
|
|
||||||
|
|
||||||
class HelpMenu(HyperglassModel):
|
class Menu(HyperglassModel):
|
||||||
"""Validation model for generic help menu."""
|
"""Validation model for generic menu."""
|
||||||
|
|
||||||
enable: StrictBool = True
|
title: StrictStr
|
||||||
file: Optional[FilePath]
|
content: StrictStr
|
||||||
title: StrictStr = "Help"
|
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):
|
class Greeting(HyperglassModel):
|
||||||
@@ -107,14 +125,6 @@ class Logo(HyperglassModel):
|
|||||||
height: Optional[Union[StrictInt, Percentage]]
|
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):
|
class Text(HyperglassModel):
|
||||||
"""Validation model for params.branding.text."""
|
"""Validation model for params.branding.text."""
|
||||||
|
|
||||||
@@ -236,11 +246,15 @@ class Web(HyperglassModel):
|
|||||||
|
|
||||||
credit: Credit = Credit()
|
credit: Credit = Credit()
|
||||||
dns_provider: DnsOverHttps = DnsOverHttps()
|
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()
|
greeting: Greeting = Greeting()
|
||||||
help_menu: HelpMenu = HelpMenu()
|
|
||||||
logo: Logo = Logo()
|
logo: Logo = Logo()
|
||||||
opengraph: OpenGraph = OpenGraph()
|
opengraph: OpenGraph = OpenGraph()
|
||||||
terms: Terms = Terms()
|
|
||||||
text: Text = Text()
|
text: Text = Text()
|
||||||
theme: Theme = Theme()
|
theme: Theme = Theme()
|
||||||
|
@@ -1,16 +1,37 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
import { Button, Menu, MenuButton, MenuList } from '@chakra-ui/react';
|
import { Button, Menu, MenuButton, MenuList } from '@chakra-ui/react';
|
||||||
import { Markdown } from '~/components';
|
import { Markdown } from '~/components';
|
||||||
import { useColorValue, useBreakpointValue } from '~/context';
|
import { useColorValue, useBreakpointValue, useConfig } from '~/context';
|
||||||
import { useOpposingColor } from '~/hooks';
|
import { useOpposingColor, useStrf } from '~/hooks';
|
||||||
|
|
||||||
|
import type { IConfig } from '~/types';
|
||||||
import type { TFooterButton } from './types';
|
import type { TFooterButton } from './types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter the configuration object based on values that are strings for formatting.
|
||||||
|
*/
|
||||||
|
function getConfigFmt(config: IConfig): Record<string, string> {
|
||||||
|
const fmt = {} as Record<string, string>;
|
||||||
|
for (const [k, v] of Object.entries(config)) {
|
||||||
|
if (typeof v === 'string') {
|
||||||
|
fmt[k] = v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fmt;
|
||||||
|
}
|
||||||
|
|
||||||
export const FooterButton: React.FC<TFooterButton> = (props: TFooterButton) => {
|
export const FooterButton: React.FC<TFooterButton> = (props: TFooterButton) => {
|
||||||
const { content, title, side, ...rest } = props;
|
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 placement = side === 'left' ? 'top' : side === 'right' ? 'top-end' : undefined;
|
||||||
const bg = useColorValue('white', 'gray.900');
|
const bg = useColorValue('white', 'gray.900');
|
||||||
const color = useOpposingColor(bg);
|
const color = useOpposingColor(bg);
|
||||||
const size = useBreakpointValue({ base: 'xs', lg: 'sm' });
|
const size = useBreakpointValue({ base: 'xs', lg: 'sm' });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Menu placement={placement} preventOverflow isLazy>
|
<Menu placement={placement} preventOverflow isLazy>
|
||||||
<MenuButton
|
<MenuButton
|
||||||
@@ -32,11 +53,12 @@ export const FooterButton: React.FC<TFooterButton> = (props: TFooterButton) => {
|
|||||||
boxShadow="2xl"
|
boxShadow="2xl"
|
||||||
textAlign="left"
|
textAlign="left"
|
||||||
overflowY="auto"
|
overflowY="auto"
|
||||||
|
whiteSpace="normal"
|
||||||
mx={{ base: 1, lg: 2 }}
|
mx={{ base: 1, lg: 2 }}
|
||||||
maxW={{ base: '100%', lg: '50vw' }}
|
maxW={{ base: '100%', lg: '50vw' }}
|
||||||
{...rest}
|
{...rest}
|
||||||
>
|
>
|
||||||
<Markdown content={content} />
|
<Markdown content={fmtContent} />
|
||||||
</MenuList>
|
</MenuList>
|
||||||
</Menu>
|
</Menu>
|
||||||
);
|
);
|
||||||
|
@@ -1,27 +1,43 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
import dynamic from 'next/dynamic';
|
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 { If } from '~/components';
|
||||||
import { useConfig, useMobile, useColorValue, useBreakpointValue } from '~/context';
|
import { useConfig, useMobile, useColorValue, useBreakpointValue } from '~/context';
|
||||||
import { useStrf } from '~/hooks';
|
import { useStrf } from '~/hooks';
|
||||||
import { FooterButton } from './button';
|
import { FooterButton } from './button';
|
||||||
import { ColorModeToggle } from './colorMode';
|
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<MeronexIcon>(() => import('@meronex/icons/fi').then(i => i.FiCode));
|
const CodeIcon = dynamic<MeronexIcon>(() => import('@meronex/icons/fi').then(i => i.FiCode));
|
||||||
const ExtIcon = dynamic<MeronexIcon>(() => import('@meronex/icons/go').then(i => i.GoLinkExternal));
|
const ExtIcon = dynamic<MeronexIcon>(() => 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 = () => {
|
export const Footer: React.FC = () => {
|
||||||
const { web, content, primary_asn } = useConfig();
|
const { web, content, primary_asn } = useConfig();
|
||||||
|
|
||||||
const footerBg = useColorValue('blackAlpha.50', 'whiteAlpha.100');
|
const footerBg = useColorValue('blackAlpha.50', 'whiteAlpha.100');
|
||||||
const footerColor = useColorValue('black', 'white');
|
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 size = useBreakpointValue({ base: useToken('sizes', 4), lg: useToken('sizes', 6) });
|
||||||
const btnSize = useBreakpointValue({ base: 'xs', lg: 'sm' });
|
|
||||||
|
|
||||||
const isMobile = useMobile();
|
const isMobile = useMobile();
|
||||||
|
|
||||||
|
const [left, right] = useMemo(() => buildItems(web.links, web.menus), []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HStack
|
<HStack
|
||||||
px={6}
|
px={6}
|
||||||
@@ -30,30 +46,42 @@ export const Footer: React.FC = () => {
|
|||||||
zIndex={1}
|
zIndex={1}
|
||||||
as="footer"
|
as="footer"
|
||||||
bg={footerBg}
|
bg={footerBg}
|
||||||
|
whiteSpace="nowrap"
|
||||||
color={footerColor}
|
color={footerColor}
|
||||||
spacing={{ base: 8, lg: 6 }}
|
spacing={{ base: 8, lg: 6 }}
|
||||||
|
d={{ base: 'inline-block', lg: 'flex' }}
|
||||||
|
overflowY={{ base: 'auto', lg: 'unset' }}
|
||||||
justifyContent={{ base: 'center', lg: 'space-between' }}
|
justifyContent={{ base: 'center', lg: 'space-between' }}
|
||||||
>
|
>
|
||||||
<If c={web.terms.enable}>
|
{left.map(item => {
|
||||||
<FooterButton side="left" content={content.terms} title={web.terms.title} />
|
if (isLink(item)) {
|
||||||
</If>
|
const url = useStrf(item.url, { primary_asn }) ?? '/';
|
||||||
<If c={web.help_menu.enable}>
|
const icon: Partial<ButtonProps & LinkProps> = {};
|
||||||
<FooterButton side="left" content={content.help_menu} title={web.help_menu.title} />
|
|
||||||
</If>
|
if (item.show_icon) {
|
||||||
<If c={web.external_link.enable}>
|
icon.rightIcon = <ExtIcon />;
|
||||||
<Button
|
}
|
||||||
as={Link}
|
return <FooterLink key={item.title} href={url} title={item.title} {...icon} />;
|
||||||
isExternal
|
} else if (isMenu(item)) {
|
||||||
href={extUrl}
|
return (
|
||||||
size={btnSize}
|
<FooterButton key={item.title} side="left" content={item.content} title={item.title} />
|
||||||
variant="ghost"
|
);
|
||||||
rightIcon={<ExtIcon />}
|
}
|
||||||
aria-label={web.external_link.title}
|
})}
|
||||||
>
|
|
||||||
{web.external_link.title}
|
|
||||||
</Button>
|
|
||||||
</If>
|
|
||||||
{!isMobile && <Flex p={0} flex="1 0 auto" maxWidth="100%" mr="auto" />}
|
{!isMobile && <Flex p={0} flex="1 0 auto" maxWidth="100%" mr="auto" />}
|
||||||
|
{right.map(item => {
|
||||||
|
if (isLink(item)) {
|
||||||
|
const url = useStrf(item.url, { primary_asn }) ?? '/';
|
||||||
|
const icon: Partial<ButtonProps & LinkProps> = {};
|
||||||
|
|
||||||
|
if (item.show_icon) {
|
||||||
|
icon.rightIcon = <ExtIcon />;
|
||||||
|
}
|
||||||
|
return <FooterLink href={url} title={item.title} {...icon} />;
|
||||||
|
} else if (isMenu(item)) {
|
||||||
|
return <FooterButton side="right" content={item.content} title={item.title} />;
|
||||||
|
}
|
||||||
|
})}
|
||||||
<If c={web.credit.enable}>
|
<If c={web.credit.enable}>
|
||||||
<FooterButton
|
<FooterButton
|
||||||
side="right"
|
side="right"
|
||||||
|
13
hyperglass/ui/components/footer/link.tsx
Normal file
13
hyperglass/ui/components/footer/link.tsx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { Button, Link, useBreakpointValue } from '@chakra-ui/react';
|
||||||
|
|
||||||
|
import type { TFooterLink } from './types';
|
||||||
|
|
||||||
|
export const FooterLink: React.FC<TFooterLink> = (props: TFooterLink) => {
|
||||||
|
const { title } = props;
|
||||||
|
const btnSize = useBreakpointValue({ base: 'xs', lg: 'sm' });
|
||||||
|
return (
|
||||||
|
<Button as={Link} isExternal size={btnSize} variant="ghost" aria-label={title} {...props}>
|
||||||
|
{title}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
};
|
@@ -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';
|
type TFooterSide = 'left' | 'right';
|
||||||
|
|
||||||
@@ -8,8 +9,18 @@ export interface TFooterButton extends Omit<MenuListProps, 'title'> {
|
|||||||
content: string;
|
content: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type TFooterLink = ButtonProps & LinkProps & { title: string };
|
||||||
|
|
||||||
export type TFooterItems = 'help' | 'credit' | 'terms';
|
export type TFooterItems = 'help' | 'credit' | 'terms';
|
||||||
|
|
||||||
export interface TColorModeToggle extends ButtonProps {
|
export interface TColorModeToggle extends ButtonProps {
|
||||||
size?: string;
|
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;
|
||||||
|
}
|
||||||
|
@@ -2,6 +2,8 @@ import type { Theme } from './theme';
|
|||||||
|
|
||||||
export type TQueryFields = 'query_type' | 'query_target' | 'query_location' | 'query_vrf';
|
export type TQueryFields = 'query_type' | 'query_target' | 'query_location' | 'query_vrf';
|
||||||
|
|
||||||
|
type TSide = 'left' | 'right';
|
||||||
|
|
||||||
export interface IConfigMessages {
|
export interface IConfigMessages {
|
||||||
no_input: string;
|
no_input: string;
|
||||||
acl_denied: string;
|
acl_denied: string;
|
||||||
@@ -62,10 +64,26 @@ export interface TConfigWebLogo {
|
|||||||
dark_format: string;
|
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 {
|
export interface IConfigWeb {
|
||||||
credit: { enable: boolean };
|
credit: { enable: boolean };
|
||||||
dns_provider: { name: string; url: string };
|
dns_provider: { name: string; url: string };
|
||||||
external_link: { enable: boolean; title: string; url: string };
|
links: TLink[];
|
||||||
|
menus: TMenu[];
|
||||||
greeting: TConfigGreeting;
|
greeting: TConfigGreeting;
|
||||||
help_menu: { enable: boolean; title: string };
|
help_menu: { enable: boolean; title: string };
|
||||||
logo: TConfigWebLogo;
|
logo: TConfigWebLogo;
|
||||||
@@ -149,8 +167,6 @@ export interface TQueryContent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface IConfigContent {
|
export interface IConfigContent {
|
||||||
help_menu: string;
|
|
||||||
terms: string;
|
|
||||||
credit: string;
|
credit: string;
|
||||||
greeting: string;
|
greeting: string;
|
||||||
vrf: {
|
vrf: {
|
||||||
|
Reference in New Issue
Block a user