forked from mirrors/thatmattlove-hyperglass
Closes #176: Deprecate native google analytics support; Add support for custom JS and HTML
This commit is contained in:
parent
8aeb8036ff
commit
0ec3086c67
14 changed files with 69 additions and 174 deletions
|
|
@ -36,31 +36,6 @@ Side = constr(regex=r"left|right")
|
||||||
LocationDisplayMode = t.Literal["auto", "dropdown", "gallery"]
|
LocationDisplayMode = t.Literal["auto", "dropdown", "gallery"]
|
||||||
|
|
||||||
|
|
||||||
class Analytics(HyperglassModel):
|
|
||||||
"""Validation model for Google Analytics."""
|
|
||||||
|
|
||||||
enable: StrictBool = False
|
|
||||||
id: t.Optional[StrictStr]
|
|
||||||
|
|
||||||
@validator("id")
|
|
||||||
def validate_id(cls, value, values):
|
|
||||||
"""Ensure ID is set if analytics is enabled.
|
|
||||||
|
|
||||||
Arguments:
|
|
||||||
value {str|None} -- Google Analytics ID
|
|
||||||
values {[type]} -- Already-validated model parameters
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
ValueError: Raised if analytics is enabled but no ID is set.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
{str|None} -- Google Analytics ID if enabled.
|
|
||||||
"""
|
|
||||||
if values["enable"] and value is None:
|
|
||||||
raise ValueError("Analytics is enabled, but no ID is set.")
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
class Credit(HyperglassModel):
|
class Credit(HyperglassModel):
|
||||||
"""Validation model for developer credit."""
|
"""Validation model for developer credit."""
|
||||||
|
|
||||||
|
|
@ -270,6 +245,8 @@ class Web(HyperglassModel):
|
||||||
text: Text = Text()
|
text: Text = Text()
|
||||||
theme: Theme = Theme()
|
theme: Theme = Theme()
|
||||||
location_display_mode: LocationDisplayMode = "auto"
|
location_display_mode: LocationDisplayMode = "auto"
|
||||||
|
custom_javascript: t.Optional[FilePath]
|
||||||
|
custom_html: t.Optional[FilePath]
|
||||||
|
|
||||||
|
|
||||||
class WebPublic(Web):
|
class WebPublic(Web):
|
||||||
|
|
|
||||||
|
|
@ -2,3 +2,4 @@ node_modules
|
||||||
dist
|
dist
|
||||||
.next/
|
.next/
|
||||||
favicon-formats.ts
|
favicon-formats.ts
|
||||||
|
custom.*[js, html]
|
||||||
|
|
|
||||||
1
hyperglass/ui/.gitignore
vendored
1
hyperglass/ui/.gitignore
vendored
|
|
@ -1,5 +1,6 @@
|
||||||
.DS_Store
|
.DS_Store
|
||||||
.env*
|
.env*
|
||||||
|
custom.*[js, html]
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
# dev/test files
|
# dev/test files
|
||||||
TODO.txt
|
TODO.txt
|
||||||
|
|
|
||||||
|
|
@ -7,3 +7,4 @@ package-lock.json
|
||||||
tsconfig.json
|
tsconfig.json
|
||||||
.next/
|
.next/
|
||||||
favicon-formats.ts
|
favicon-formats.ts
|
||||||
|
custom.*[js, html]
|
||||||
|
|
|
||||||
25
hyperglass/ui/components/custom.tsx
Normal file
25
hyperglass/ui/components/custom.tsx
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
/**
|
||||||
|
* Render a generic script tag in the `<head/>` that contains any custom-defined Javascript, if
|
||||||
|
* defined. It no custom JS is defined, an empty fragment is rendered, which will not appear in
|
||||||
|
* the DOM.
|
||||||
|
*/
|
||||||
|
export const CustomJavascript = (props: React.PropsWithChildren<Dict>): JSX.Element => {
|
||||||
|
const { children } = props;
|
||||||
|
if (typeof children === 'string' && children !== '') {
|
||||||
|
return <script id="custom-javascript" dangerouslySetInnerHTML={{ __html: children }} />;
|
||||||
|
}
|
||||||
|
return <></>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render a generic div outside of the main application that contains any custom-defined HTML, if
|
||||||
|
* defined. If no custom HTML is defined, an empty fragment is rendered, which will not appear in
|
||||||
|
* the DOM.
|
||||||
|
*/
|
||||||
|
export const CustomHtml = (props: React.PropsWithChildren<Dict>): JSX.Element => {
|
||||||
|
const { children } = props;
|
||||||
|
if (typeof children === 'string' && children !== '') {
|
||||||
|
return <div id="custom-html" dangerouslySetInnerHTML={{ __html: children }} />;
|
||||||
|
}
|
||||||
|
return <></>;
|
||||||
|
};
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
export * from './card';
|
export * from './card';
|
||||||
export * from './codeBlock';
|
export * from './codeBlock';
|
||||||
export * from './countdown';
|
export * from './countdown';
|
||||||
|
export * from './custom';
|
||||||
export * from './debugger';
|
export * from './debugger';
|
||||||
export * from './favicon';
|
export * from './favicon';
|
||||||
export * from './footer';
|
export * from './footer';
|
||||||
|
|
|
||||||
|
|
@ -1,23 +1,19 @@
|
||||||
import { useCallback, useEffect, useRef } from 'react';
|
import { useCallback, useRef } from 'react';
|
||||||
import { useRouter } from 'next/router';
|
|
||||||
import { Flex } from '@chakra-ui/react';
|
import { Flex } from '@chakra-ui/react';
|
||||||
import { isSafari } from 'react-device-detect';
|
import { isSafari } from 'react-device-detect';
|
||||||
import { If, Debugger, Greeting, Footer, Header } from '~/components';
|
import { If, Debugger, Greeting, Footer, Header } from '~/components';
|
||||||
import { useConfig } from '~/context';
|
import { useConfig } from '~/context';
|
||||||
import { useGoogleAnalytics, useFormState } from '~/hooks';
|
import { useFormState } from '~/hooks';
|
||||||
import { ResetButton } from './resetButton';
|
import { ResetButton } from './resetButton';
|
||||||
|
|
||||||
import type { TFrame } from './types';
|
import type { TFrame } from './types';
|
||||||
|
|
||||||
export const Frame = (props: TFrame): JSX.Element => {
|
export const Frame = (props: TFrame): JSX.Element => {
|
||||||
const router = useRouter();
|
const { developerMode } = useConfig();
|
||||||
const { developerMode, googleAnalytics } = useConfig();
|
|
||||||
const { setStatus, reset } = useFormState(
|
const { setStatus, reset } = useFormState(
|
||||||
useCallback(({ setStatus, reset }) => ({ setStatus, reset }), []),
|
useCallback(({ setStatus, reset }) => ({ setStatus, reset }), []),
|
||||||
);
|
);
|
||||||
|
|
||||||
const { initialize, trackPage } = useGoogleAnalytics();
|
|
||||||
|
|
||||||
const containerRef = useRef<HTMLDivElement>({} as HTMLDivElement);
|
const containerRef = useRef<HTMLDivElement>({} as HTMLDivElement);
|
||||||
|
|
||||||
function handleReset(): void {
|
function handleReset(): void {
|
||||||
|
|
@ -26,15 +22,6 @@ export const Frame = (props: TFrame): JSX.Element => {
|
||||||
reset();
|
reset();
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
initialize(googleAnalytics, developerMode);
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
trackPage(router.pathname);
|
|
||||||
}, [router.pathname, trackPage]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Flex
|
<Flex
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ export * from './useDevice';
|
||||||
export * from './useDirective';
|
export * from './useDirective';
|
||||||
export * from './useDNSQuery';
|
export * from './useDNSQuery';
|
||||||
export * from './useFormState';
|
export * from './useFormState';
|
||||||
export * from './useGoogleAnalytics';
|
|
||||||
export * from './useGreeting';
|
export * from './useGreeting';
|
||||||
export * from './useHyperglassConfig';
|
export * from './useHyperglassConfig';
|
||||||
export * from './useLGQuery';
|
export * from './useLGQuery';
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import { useQuery } from 'react-query';
|
import { useQuery } from 'react-query';
|
||||||
import { useConfig } from '~/context';
|
import { useConfig } from '~/context';
|
||||||
import { fetchWithTimeout } from '~/util';
|
import { fetchWithTimeout } from '~/util';
|
||||||
import { useGoogleAnalytics } from './useGoogleAnalytics';
|
|
||||||
|
|
||||||
import type { QueryFunction, QueryFunctionContext, QueryObserverResult } from 'react-query';
|
import type { QueryFunction, QueryFunctionContext, QueryObserverResult } from 'react-query';
|
||||||
import type { DnsOverHttps } from '~/types';
|
import type { DnsOverHttps } from '~/types';
|
||||||
|
|
@ -52,11 +51,6 @@ export function useDNSQuery(
|
||||||
family: 4 | 6,
|
family: 4 | 6,
|
||||||
): QueryObserverResult<DnsOverHttps.Response> {
|
): QueryObserverResult<DnsOverHttps.Response> {
|
||||||
const { cache, web } = useConfig();
|
const { cache, web } = useConfig();
|
||||||
const { trackEvent } = useGoogleAnalytics();
|
|
||||||
|
|
||||||
if (typeof target === 'string') {
|
|
||||||
trackEvent({ category: 'DNS', action: 'Query', label: target, dimension1: `IPv${family}` });
|
|
||||||
}
|
|
||||||
|
|
||||||
return useQuery<DnsOverHttps.Response, unknown, DnsOverHttps.Response, DNSQueryKey>({
|
return useQuery<DnsOverHttps.Response, unknown, DnsOverHttps.Response, DNSQueryKey>({
|
||||||
queryKey: [web.dnsProvider.url, { target, family }],
|
queryKey: [web.dnsProvider.url, { target, family }],
|
||||||
|
|
|
||||||
|
|
@ -1,110 +0,0 @@
|
||||||
import create from 'zustand';
|
|
||||||
import { useCallback } from 'react';
|
|
||||||
import * as ReactGA from 'react-ga';
|
|
||||||
|
|
||||||
import type { GAEffect, GAReturn } from './types';
|
|
||||||
|
|
||||||
interface EnabledState {
|
|
||||||
enabled: boolean;
|
|
||||||
enable(): void;
|
|
||||||
disable(): void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const useEnabled = create<EnabledState>(set => ({
|
|
||||||
enabled: false,
|
|
||||||
enable() {
|
|
||||||
set({ enabled: true });
|
|
||||||
},
|
|
||||||
disable() {
|
|
||||||
set({ enabled: false });
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
export function useGoogleAnalytics(): GAReturn {
|
|
||||||
const { enabled, enable } = useEnabled(({ enable, enabled }) => ({ enable, enabled }));
|
|
||||||
|
|
||||||
const runEffect = useCallback(
|
|
||||||
(effect: GAEffect): void => {
|
|
||||||
if (typeof window !== 'undefined' && enabled) {
|
|
||||||
if (typeof effect === 'function') {
|
|
||||||
effect(ReactGA);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[enabled],
|
|
||||||
);
|
|
||||||
|
|
||||||
const trackEvent = useCallback(
|
|
||||||
(e: ReactGA.EventArgs) => {
|
|
||||||
runEffect(ga => {
|
|
||||||
if (process.env.NODE_ENV === 'production') {
|
|
||||||
ga.event(e);
|
|
||||||
} else {
|
|
||||||
console.log(
|
|
||||||
`%cEvent %c${JSON.stringify(e)}`,
|
|
||||||
'background: green; color: black; padding: 0.5rem; font-size: 0.75rem;',
|
|
||||||
'background: black; color: green; padding: 0.5rem; font-size: 0.75rem; font-weight: bold;',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[runEffect],
|
|
||||||
);
|
|
||||||
|
|
||||||
const trackPage = useCallback(
|
|
||||||
(path: string) => {
|
|
||||||
runEffect(ga => {
|
|
||||||
if (process.env.NODE_ENV === 'production') {
|
|
||||||
ga.pageview(path);
|
|
||||||
} else {
|
|
||||||
console.log(
|
|
||||||
`%cPage View %c${path}`,
|
|
||||||
'background: blue; color: white; padding: 0.5rem; font-size: 0.75rem;',
|
|
||||||
'background: white; color: blue; padding: 0.5rem; font-size: 0.75rem; font-weight: bold;',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[runEffect],
|
|
||||||
);
|
|
||||||
|
|
||||||
const trackModal = useCallback(
|
|
||||||
(path: string) => {
|
|
||||||
runEffect(ga => {
|
|
||||||
if (process.env.NODE_ENV === 'production') {
|
|
||||||
ga.modalview(path);
|
|
||||||
} else {
|
|
||||||
console.log(
|
|
||||||
`%cModal View %c${path}`,
|
|
||||||
'background: red; color: white; padding: 0.5rem; font-size: 0.75rem;',
|
|
||||||
'background: white; color: red; padding: 0.5rem; font-size: 0.75rem; font-weight: bold;',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[runEffect],
|
|
||||||
);
|
|
||||||
|
|
||||||
const initialize = useCallback(
|
|
||||||
(trackingId: string, debug: boolean) => {
|
|
||||||
if (typeof trackingId !== 'string') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
enable();
|
|
||||||
|
|
||||||
const initializeOpts = { titleCase: false } as ReactGA.InitializeOptions;
|
|
||||||
|
|
||||||
if (debug) {
|
|
||||||
initializeOpts.debug = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
runEffect(ga => {
|
|
||||||
ga.initialize(trackingId, initializeOpts);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[runEffect, enable],
|
|
||||||
);
|
|
||||||
|
|
||||||
return { trackEvent, trackModal, trackPage, initialize, ga: ReactGA };
|
|
||||||
}
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import { useEffect, useMemo } from 'react';
|
import { useEffect, useMemo } from 'react';
|
||||||
import { useQuery } from 'react-query';
|
import { useQuery } from 'react-query';
|
||||||
import { useConfig } from '~/context';
|
import { useConfig } from '~/context';
|
||||||
import { useGoogleAnalytics } from './useGoogleAnalytics';
|
|
||||||
import { fetchWithTimeout } from '~/util';
|
import { fetchWithTimeout } from '~/util';
|
||||||
|
|
||||||
import type { QueryFunction, QueryFunctionContext, QueryObserverResult } from 'react-query';
|
import type { QueryFunction, QueryFunctionContext, QueryObserverResult } from 'react-query';
|
||||||
|
|
@ -18,16 +17,6 @@ export function useLGQuery(
|
||||||
const { requestTimeout, cache } = useConfig();
|
const { requestTimeout, cache } = useConfig();
|
||||||
const controller = useMemo(() => new AbortController(), []);
|
const controller = useMemo(() => new AbortController(), []);
|
||||||
|
|
||||||
const { trackEvent } = useGoogleAnalytics();
|
|
||||||
|
|
||||||
trackEvent({
|
|
||||||
category: 'Query',
|
|
||||||
action: 'submit',
|
|
||||||
dimension1: query.queryLocation,
|
|
||||||
dimension2: query.queryTarget,
|
|
||||||
dimension3: query.queryType,
|
|
||||||
});
|
|
||||||
|
|
||||||
const runQuery: QueryFunction<QueryResponse, LGQueryKey> = async (
|
const runQuery: QueryFunction<QueryResponse, LGQueryKey> = async (
|
||||||
ctx: QueryFunctionContext<LGQueryKey>,
|
ctx: QueryFunctionContext<LGQueryKey>,
|
||||||
): Promise<QueryResponse> => {
|
): Promise<QueryResponse> => {
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,21 @@
|
||||||
|
import fs from 'fs';
|
||||||
import Document, { Html, Head, Main, NextScript } from 'next/document';
|
import Document, { Html, Head, Main, NextScript } from 'next/document';
|
||||||
import { Favicon } from '~/components';
|
import { Favicon, CustomJavascript, CustomHtml } from '~/components';
|
||||||
import favicons from '../favicon-formats';
|
import favicons from '../favicon-formats';
|
||||||
|
|
||||||
import type { DocumentContext, DocumentInitialProps } from 'next/document';
|
import type { DocumentContext, DocumentInitialProps } from 'next/document';
|
||||||
|
|
||||||
class MyDocument extends Document {
|
interface DocumentExtra extends DocumentInitialProps {
|
||||||
static async getInitialProps(ctx: DocumentContext): Promise<DocumentInitialProps> {
|
customJs: string;
|
||||||
|
customHtml: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
class MyDocument extends Document<DocumentExtra> {
|
||||||
|
static async getInitialProps(ctx: DocumentContext): Promise<DocumentExtra> {
|
||||||
const initialProps = await Document.getInitialProps(ctx);
|
const initialProps = await Document.getInitialProps(ctx);
|
||||||
return { ...initialProps };
|
const customJs = fs.readFileSync('custom.js').toString();
|
||||||
|
const customHtml = fs.readFileSync('custom.html').toString();
|
||||||
|
return { customJs, customHtml, ...initialProps };
|
||||||
}
|
}
|
||||||
|
|
||||||
render(): JSX.Element {
|
render(): JSX.Element {
|
||||||
|
|
@ -31,9 +39,11 @@ class MyDocument extends Document {
|
||||||
{favicons.map((favicon, idx) => (
|
{favicons.map((favicon, idx) => (
|
||||||
<Favicon key={idx} {...favicon} />
|
<Favicon key={idx} {...favicon} />
|
||||||
))}
|
))}
|
||||||
|
<CustomJavascript>{this.props.customJs}</CustomJavascript>
|
||||||
</Head>
|
</Head>
|
||||||
<body>
|
<body>
|
||||||
<Main />
|
<Main />
|
||||||
|
<CustomHtml>{this.props.customHtml}</CustomHtml>
|
||||||
<NextScript />
|
<NextScript />
|
||||||
</body>
|
</body>
|
||||||
</Html>
|
</Html>
|
||||||
|
|
|
||||||
|
|
@ -172,7 +172,6 @@ interface _ConfigShallow {
|
||||||
primary_asn: string;
|
primary_asn: string;
|
||||||
request_timeout: number;
|
request_timeout: number;
|
||||||
org_name: string;
|
org_name: string;
|
||||||
google_analytics: string | null;
|
|
||||||
site_title: string;
|
site_title: string;
|
||||||
site_keywords: string[];
|
site_keywords: string[];
|
||||||
site_description: string;
|
site_description: string;
|
||||||
|
|
|
||||||
|
|
@ -259,6 +259,25 @@ def write_favicon_formats(formats: t.Tuple[t.Dict[str, t.Any]]) -> None:
|
||||||
file.write_text(data)
|
file.write_text(data)
|
||||||
|
|
||||||
|
|
||||||
|
def write_custom_files(params: "UIParameters") -> None:
|
||||||
|
"""Write custom files to the `ui` directory so they can be imported and rendered."""
|
||||||
|
js = Path(__file__).parent.parent / "ui" / "custom.js"
|
||||||
|
html = Path(__file__).parent.parent / "ui" / "custom.html"
|
||||||
|
|
||||||
|
# Handle Custom JS.
|
||||||
|
if params.web.custom_javascript is not None:
|
||||||
|
copyfiles((params.web.custom_javascript,), (js,))
|
||||||
|
else:
|
||||||
|
with js.open("w") as f:
|
||||||
|
f.write("")
|
||||||
|
# Handle Custom HTML.
|
||||||
|
if params.web.custom_html is not None:
|
||||||
|
copyfiles((params.web.custom_html,), (html,))
|
||||||
|
else:
|
||||||
|
with html.open("w") as f:
|
||||||
|
f.write("")
|
||||||
|
|
||||||
|
|
||||||
async def build_frontend( # noqa: C901
|
async def build_frontend( # noqa: C901
|
||||||
dev_mode: bool,
|
dev_mode: bool,
|
||||||
dev_url: str,
|
dev_url: str,
|
||||||
|
|
@ -370,6 +389,8 @@ async def build_frontend( # noqa: C901
|
||||||
|
|
||||||
migrate_images(app_path, params)
|
migrate_images(app_path, params)
|
||||||
|
|
||||||
|
write_custom_files(params)
|
||||||
|
|
||||||
generate_opengraph(
|
generate_opengraph(
|
||||||
params.web.opengraph.image,
|
params.web.opengraph.image,
|
||||||
1200,
|
1200,
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue