mirror of
https://github.com/thatmattlove/hyperglass.git
synced 2026-01-17 08:48:05 +00:00
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"]
|
||||
|
||||
|
||||
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):
|
||||
"""Validation model for developer credit."""
|
||||
|
||||
|
|
@ -270,6 +245,8 @@ class Web(HyperglassModel):
|
|||
text: Text = Text()
|
||||
theme: Theme = Theme()
|
||||
location_display_mode: LocationDisplayMode = "auto"
|
||||
custom_javascript: t.Optional[FilePath]
|
||||
custom_html: t.Optional[FilePath]
|
||||
|
||||
|
||||
class WebPublic(Web):
|
||||
|
|
|
|||
|
|
@ -2,3 +2,4 @@ node_modules
|
|||
dist
|
||||
.next/
|
||||
favicon-formats.ts
|
||||
custom.*[js, html]
|
||||
|
|
|
|||
1
hyperglass/ui/.gitignore
vendored
1
hyperglass/ui/.gitignore
vendored
|
|
@ -1,5 +1,6 @@
|
|||
.DS_Store
|
||||
.env*
|
||||
custom.*[js, html]
|
||||
*.tsbuildinfo
|
||||
# dev/test files
|
||||
TODO.txt
|
||||
|
|
|
|||
|
|
@ -7,3 +7,4 @@ package-lock.json
|
|||
tsconfig.json
|
||||
.next/
|
||||
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 './codeBlock';
|
||||
export * from './countdown';
|
||||
export * from './custom';
|
||||
export * from './debugger';
|
||||
export * from './favicon';
|
||||
export * from './footer';
|
||||
|
|
|
|||
|
|
@ -1,23 +1,19 @@
|
|||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useCallback, useRef } from 'react';
|
||||
import { Flex } from '@chakra-ui/react';
|
||||
import { isSafari } from 'react-device-detect';
|
||||
import { If, Debugger, Greeting, Footer, Header } from '~/components';
|
||||
import { useConfig } from '~/context';
|
||||
import { useGoogleAnalytics, useFormState } from '~/hooks';
|
||||
import { useFormState } from '~/hooks';
|
||||
import { ResetButton } from './resetButton';
|
||||
|
||||
import type { TFrame } from './types';
|
||||
|
||||
export const Frame = (props: TFrame): JSX.Element => {
|
||||
const router = useRouter();
|
||||
const { developerMode, googleAnalytics } = useConfig();
|
||||
const { developerMode } = useConfig();
|
||||
const { setStatus, reset } = useFormState(
|
||||
useCallback(({ setStatus, reset }) => ({ setStatus, reset }), []),
|
||||
);
|
||||
|
||||
const { initialize, trackPage } = useGoogleAnalytics();
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>({} as HTMLDivElement);
|
||||
|
||||
function handleReset(): void {
|
||||
|
|
@ -26,15 +22,6 @@ export const Frame = (props: TFrame): JSX.Element => {
|
|||
reset();
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
initialize(googleAnalytics, developerMode);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
trackPage(router.pathname);
|
||||
}, [router.pathname, trackPage]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Flex
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ export * from './useDevice';
|
|||
export * from './useDirective';
|
||||
export * from './useDNSQuery';
|
||||
export * from './useFormState';
|
||||
export * from './useGoogleAnalytics';
|
||||
export * from './useGreeting';
|
||||
export * from './useHyperglassConfig';
|
||||
export * from './useLGQuery';
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import { useQuery } from 'react-query';
|
||||
import { useConfig } from '~/context';
|
||||
import { fetchWithTimeout } from '~/util';
|
||||
import { useGoogleAnalytics } from './useGoogleAnalytics';
|
||||
|
||||
import type { QueryFunction, QueryFunctionContext, QueryObserverResult } from 'react-query';
|
||||
import type { DnsOverHttps } from '~/types';
|
||||
|
|
@ -52,11 +51,6 @@ export function useDNSQuery(
|
|||
family: 4 | 6,
|
||||
): QueryObserverResult<DnsOverHttps.Response> {
|
||||
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>({
|
||||
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 { useQuery } from 'react-query';
|
||||
import { useConfig } from '~/context';
|
||||
import { useGoogleAnalytics } from './useGoogleAnalytics';
|
||||
import { fetchWithTimeout } from '~/util';
|
||||
|
||||
import type { QueryFunction, QueryFunctionContext, QueryObserverResult } from 'react-query';
|
||||
|
|
@ -18,16 +17,6 @@ export function useLGQuery(
|
|||
const { requestTimeout, cache } = useConfig();
|
||||
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 (
|
||||
ctx: QueryFunctionContext<LGQueryKey>,
|
||||
): Promise<QueryResponse> => {
|
||||
|
|
|
|||
|
|
@ -1,13 +1,21 @@
|
|||
import fs from 'fs';
|
||||
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 type { DocumentContext, DocumentInitialProps } from 'next/document';
|
||||
|
||||
class MyDocument extends Document {
|
||||
static async getInitialProps(ctx: DocumentContext): Promise<DocumentInitialProps> {
|
||||
interface DocumentExtra extends DocumentInitialProps {
|
||||
customJs: string;
|
||||
customHtml: string;
|
||||
}
|
||||
|
||||
class MyDocument extends Document<DocumentExtra> {
|
||||
static async getInitialProps(ctx: DocumentContext): Promise<DocumentExtra> {
|
||||
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 {
|
||||
|
|
@ -31,9 +39,11 @@ class MyDocument extends Document {
|
|||
{favicons.map((favicon, idx) => (
|
||||
<Favicon key={idx} {...favicon} />
|
||||
))}
|
||||
<CustomJavascript>{this.props.customJs}</CustomJavascript>
|
||||
</Head>
|
||||
<body>
|
||||
<Main />
|
||||
<CustomHtml>{this.props.customHtml}</CustomHtml>
|
||||
<NextScript />
|
||||
</body>
|
||||
</Html>
|
||||
|
|
|
|||
|
|
@ -172,7 +172,6 @@ interface _ConfigShallow {
|
|||
primary_asn: string;
|
||||
request_timeout: number;
|
||||
org_name: string;
|
||||
google_analytics: string | null;
|
||||
site_title: string;
|
||||
site_keywords: 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)
|
||||
|
||||
|
||||
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
|
||||
dev_mode: bool,
|
||||
dev_url: str,
|
||||
|
|
@ -370,6 +389,8 @@ async def build_frontend( # noqa: C901
|
|||
|
||||
migrate_images(app_path, params)
|
||||
|
||||
write_custom_files(params)
|
||||
|
||||
generate_opengraph(
|
||||
params.web.opengraph.image,
|
||||
1200,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue