mirror of
https://github.com/plausible/analytics.git
synced 2024-12-22 17:11:36 +03:00
Add Typescript (#4427)
* Add typescript, rewrite Countries map * Add back DB IP geolocation notice * Silence all current eslint warnings: to be removed gradually * Reconfigure eslint import plugin for typescript * Insert formatting pragma by default, but ignore files without pragma in CI
This commit is contained in:
parent
ee3d1e770e
commit
039f3baf8e
@ -1,3 +1,3 @@
|
|||||||
erlang 27.0
|
erlang 27.0
|
||||||
elixir 1.17.1-otp-27
|
elixir 1.17.1-otp-27
|
||||||
nodejs 21.0.0
|
nodejs 21.7.3
|
||||||
|
@ -1,22 +1,20 @@
|
|||||||
{
|
{
|
||||||
"parserOptions": {
|
"root": true,
|
||||||
"ecmaVersion": "latest",
|
|
||||||
"sourceType": "module"
|
|
||||||
},
|
|
||||||
"env": {
|
"env": {
|
||||||
"browser": true,
|
"browser": true,
|
||||||
"es6": true
|
"es6": true
|
||||||
},
|
},
|
||||||
|
"plugins": ["import"],
|
||||||
"extends": [
|
"extends": [
|
||||||
"eslint:recommended",
|
"eslint:recommended",
|
||||||
|
"plugin:@typescript-eslint/recommended",
|
||||||
"plugin:jsx-a11y/recommended",
|
"plugin:jsx-a11y/recommended",
|
||||||
"plugin:import/recommended",
|
|
||||||
"plugin:react/recommended",
|
"plugin:react/recommended",
|
||||||
"plugin:react-hooks/recommended",
|
"plugin:react-hooks/recommended",
|
||||||
"prettier"
|
"prettier"
|
||||||
],
|
],
|
||||||
"rules": {
|
"rules": {
|
||||||
"react/jsx-filename-extension": [1, { "extensions": [".js", ".jsx"] }],
|
"import/no-unresolved": "error",
|
||||||
"react/destructuring-assignment": [0],
|
"react/destructuring-assignment": [0],
|
||||||
"react/prop-types": [0],
|
"react/prop-types": [0],
|
||||||
"max-classes-per-file": [0],
|
"max-classes-per-file": [0],
|
||||||
@ -24,20 +22,39 @@
|
|||||||
"react/jsx-one-expression-per-line": [0],
|
"react/jsx-one-expression-per-line": [0],
|
||||||
"react/self-closing-comp": [0],
|
"react/self-closing-comp": [0],
|
||||||
"no-unused-expressions": [1, { "allowShortCircuit": true }],
|
"no-unused-expressions": [1, { "allowShortCircuit": true }],
|
||||||
"no-unused-vars": [2, { "varsIgnorePattern": "^_", "argsIgnorePattern": "^_" }],
|
"@typescript-eslint/no-unused-vars": [
|
||||||
|
2,
|
||||||
|
{
|
||||||
|
"args": "all",
|
||||||
|
"argsIgnorePattern": "^_",
|
||||||
|
"caughtErrors": "all",
|
||||||
|
"caughtErrorsIgnorePattern": "^_",
|
||||||
|
"destructuredArrayIgnorePattern": "^_",
|
||||||
|
"varsIgnorePattern": "^_",
|
||||||
|
"ignoreRestSiblings": true
|
||||||
|
}
|
||||||
|
],
|
||||||
"no-prototype-builtins": [0],
|
"no-prototype-builtins": [0],
|
||||||
"react/jsx-props-no-spreading": [0],
|
"react/jsx-props-no-spreading": [0],
|
||||||
"jsx-a11y/click-events-have-key-events": [0],
|
"jsx-a11y/click-events-have-key-events": [0],
|
||||||
"jsx-a11y/no-static-element-interactions": [0],
|
"jsx-a11y/no-static-element-interactions": [0],
|
||||||
"react/no-did-update-set-state": [0],
|
"react/no-did-update-set-state": [0],
|
||||||
"react/no-unknown-property": [2, {"ignore": ["tooltip"]}]
|
"react/no-unknown-property": [2, { "ignore": ["tooltip"] }]
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
|
"import/parsers": {
|
||||||
|
"@typescript-eslint/parser": [".ts", ".tsx"]
|
||||||
|
},
|
||||||
|
"import/resolver": {
|
||||||
|
"typescript": {
|
||||||
|
"alwaysTryTypes": true // always try to resolve types under `<root>@types` directory even it doesn't contain any source code, like `@types/unist`
|
||||||
|
}
|
||||||
|
},
|
||||||
"react": {
|
"react": {
|
||||||
"createClass": "createReactClass", // Regex for Component Factory to use,
|
"createClass": "createReactClass", // Regex for Component Factory to use,
|
||||||
// default to "createReactClass"
|
// default to "createReactClass"
|
||||||
"pragma": "React", // Pragma to use, default to "React"
|
"pragma": "React", // Pragma to use, default to "React"
|
||||||
"fragment": "Fragment", // Fragment to use (may be a property of <pragma>), default to "Fragment"
|
"fragment": "Fragment", // Fragment to use (may be a property of <pragma>), default to "Fragment"
|
||||||
"version": "detect"
|
"version": "detect"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"singleQuote": true,
|
"singleQuote": true,
|
||||||
"requirePragma": true,
|
"insertPragma": true,
|
||||||
"trailingComma": "none",
|
"trailingComma": "none",
|
||||||
"semi": false
|
"semi": false
|
||||||
}
|
}
|
||||||
|
@ -255,11 +255,6 @@ blockquote {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Only because the map handler doesn't expose an easier way to change the shadow color */
|
|
||||||
.dark .hoverinfo {
|
|
||||||
box-shadow: 1px 1px 5px rgb(26 32 44);
|
|
||||||
}
|
|
||||||
|
|
||||||
.fullwidth-shadow::before {
|
.fullwidth-shadow::before {
|
||||||
@apply absolute top-0 w-screen h-full bg-gray-50 dark:bg-gray-850;
|
@apply absolute top-0 w-screen h-full bg-gray-50 dark:bg-gray-850;
|
||||||
|
|
||||||
|
@ -10,6 +10,7 @@ import * as timer from './dashboard/util/realtime-update-timer'
|
|||||||
import { filtersBackwardsCompatibilityRedirect } from './dashboard/query';
|
import { filtersBackwardsCompatibilityRedirect } from './dashboard/query';
|
||||||
import SiteContextProvider, { parseSiteFromDataset } from './dashboard/site-context';
|
import SiteContextProvider, { parseSiteFromDataset } from './dashboard/site-context';
|
||||||
import UserContextProvider from './dashboard/user-context'
|
import UserContextProvider from './dashboard/user-context'
|
||||||
|
import ThemeContextProvider from './dashboard/theme-context'
|
||||||
|
|
||||||
timer.start()
|
timer.start()
|
||||||
|
|
||||||
@ -32,11 +33,13 @@ if (container) {
|
|||||||
const router = createAppRouter(site);
|
const router = createAppRouter(site);
|
||||||
const app = (
|
const app = (
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<SiteContextProvider site={site}>
|
<ThemeContextProvider>
|
||||||
<UserContextProvider role={container.dataset.currentUserRole} loggedIn={container.dataset.loggedIn === 'true'}>
|
<SiteContextProvider site={site}>
|
||||||
<RouterProvider router={router} />
|
<UserContextProvider role={container.dataset.currentUserRole} loggedIn={container.dataset.loggedIn === 'true'}>
|
||||||
</UserContextProvider>
|
<RouterProvider router={router} />
|
||||||
</SiteContextProvider>
|
</UserContextProvider>
|
||||||
|
</SiteContextProvider>
|
||||||
|
</ThemeContextProvider>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -10,8 +10,9 @@ export default function LazyLoader(props) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (inView && !hasBecomeVisibleYet) {
|
if (inView && !hasBecomeVisibleYet) {
|
||||||
setHasBecomeVisibleYet(true)
|
setHasBecomeVisibleYet(true)
|
||||||
props.onVisible && props.onVisible()
|
if (props.onVisible) props.onVisible()
|
||||||
}
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [inView])
|
}, [inView])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
/* eslint-disable react-hooks/exhaustive-deps */
|
||||||
import React, { Fragment, useState, useEffect, useCallback, useRef } from "react";
|
import React, { Fragment, useState, useEffect, useCallback, useRef } from "react";
|
||||||
import { useAppNavigate } from "./navigation/use-app-navigate";
|
import { useAppNavigate } from "./navigation/use-app-navigate";
|
||||||
import Flatpickr from "react-flatpickr";
|
import Flatpickr from "react-flatpickr";
|
||||||
@ -291,7 +292,7 @@ function DatePicker() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function openCalendar() {
|
function openCalendar() {
|
||||||
calendar.current && calendar.current.flatpickr.open();
|
calendar.current?.flatpickr.open();
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderLink(period, text, opts = {}) {
|
function renderLink(period, text, opts = {}) {
|
||||||
|
@ -46,12 +46,14 @@ export default function Funnel({ funnelName, tabs }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [query, funnelName, visible, isSmallScreen])
|
}, [query, funnelName, visible, isSmallScreen])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (canvasRef.current && funnel && visible && !isSmallScreen) {
|
if (canvasRef.current && funnel && visible && !isSmallScreen) {
|
||||||
initialiseChart()
|
initialiseChart()
|
||||||
}
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [funnel, visible])
|
}, [funnel, visible])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
@ -137,6 +137,7 @@ function Filters() {
|
|||||||
window.removeEventListener('resize', handleResize, false)
|
window.removeEventListener('resize', handleResize, false)
|
||||||
document.removeEventListener("keyup", handleKeyup)
|
document.removeEventListener("keyup", handleKeyup)
|
||||||
}
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -145,6 +146,7 @@ function Filters() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (wrapped === WRAPSTATE.waiting) { updateDisplayMode() }
|
if (wrapped === WRAPSTATE.waiting) { updateDisplayMode() }
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [wrapped])
|
}, [wrapped])
|
||||||
|
|
||||||
|
|
||||||
|
@ -1,65 +0,0 @@
|
|||||||
import React, { forwardRef, useCallback } from 'react'
|
|
||||||
import { Link, useLocation, useNavigate, generatePath } from 'react-router-dom'
|
|
||||||
import { parseSearch, stringifySearch } from '../util/url'
|
|
||||||
|
|
||||||
const getNavigateToOptions = (
|
|
||||||
currentSearchString,
|
|
||||||
{ path, params, search }
|
|
||||||
) => {
|
|
||||||
const searchRecord = parseSearch(currentSearchString)
|
|
||||||
const updatedSearchRecord = search && search(searchRecord)
|
|
||||||
const updatedPath = path && generatePath(path, params)
|
|
||||||
return {
|
|
||||||
pathname: updatedPath,
|
|
||||||
search: updatedSearchRecord && stringifySearch(updatedSearchRecord)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useGetNavigateOptions = () => {
|
|
||||||
const location = useLocation()
|
|
||||||
/**
|
|
||||||
* @param path - path to target, for example `"/posts"` or `"/posts/:id"`
|
|
||||||
* @param params - dictionary of param keys with their values, if needed, for example `{ id: "some-id" }`
|
|
||||||
* @param search -
|
|
||||||
* function in the form of `(currentSearchRecord) => newSearchRecord` to set link search value, for example
|
|
||||||
* `(s) => s` preserves current search value,
|
|
||||||
* `() => ({page: 5})` sets the search to `?page=5`,
|
|
||||||
* `undefined` empties the search
|
|
||||||
* @returns the appropriate value for `react-router-dom` `Link` and `navigate` `to` property
|
|
||||||
*/
|
|
||||||
const getToOptions = useCallback(
|
|
||||||
({ path, params, search }) => {
|
|
||||||
return getNavigateToOptions(location.search, { path, params, search })
|
|
||||||
},
|
|
||||||
[location.search]
|
|
||||||
)
|
|
||||||
return getToOptions
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useAppNavigate = () => {
|
|
||||||
const _navigate = useNavigate()
|
|
||||||
const getToOptions = useGetNavigateOptions()
|
|
||||||
const navigate = useCallback(
|
|
||||||
({ path, params, search, ...options }) => {
|
|
||||||
return _navigate(getToOptions({ path, params, search }), options)
|
|
||||||
},
|
|
||||||
[getToOptions, _navigate]
|
|
||||||
)
|
|
||||||
return navigate
|
|
||||||
}
|
|
||||||
|
|
||||||
export const AppNavigationLink = forwardRef(
|
|
||||||
({ path, params, search, ...options }, ref) => {
|
|
||||||
const getToOptions = useGetNavigateOptions()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
to={getToOptions({ path, params, search })}
|
|
||||||
{...options}
|
|
||||||
ref={ref}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
AppNavigationLink.displayName = 'AppNavigationLink'
|
|
85
assets/js/dashboard/navigation/use-app-navigate.tsx
Normal file
85
assets/js/dashboard/navigation/use-app-navigate.tsx
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
/* @format */
|
||||||
|
import React, { forwardRef, useCallback } from 'react'
|
||||||
|
import {
|
||||||
|
Link,
|
||||||
|
useLocation,
|
||||||
|
useNavigate,
|
||||||
|
generatePath,
|
||||||
|
Params,
|
||||||
|
NavigateOptions,
|
||||||
|
LinkProps
|
||||||
|
} from 'react-router-dom'
|
||||||
|
import { parseSearch, stringifySearch } from '../util/url'
|
||||||
|
|
||||||
|
type AppNavigationTarget = {
|
||||||
|
/**
|
||||||
|
* path to target, for example `"/posts"` or `"/posts/:id"`
|
||||||
|
*/
|
||||||
|
path?: string
|
||||||
|
/**
|
||||||
|
* dictionary of param keys with their values, if needed, for example `{ id: "some-id" }`
|
||||||
|
*/
|
||||||
|
params?: Params
|
||||||
|
/**
|
||||||
|
* function in the form of `(currentSearchRecord) => newSearchRecord` to set link search value, for example
|
||||||
|
* - `(s) => s` preserves current search value,
|
||||||
|
* - `(s) => ({ ...s, calendar: !s.calendar })` toggles the value for calendar search parameter,
|
||||||
|
* - `() => ({ page: 5 })` sets the search to `?page=5`,
|
||||||
|
* - `undefined` empties the search
|
||||||
|
*/
|
||||||
|
search?: (search: Record<string, unknown>) => Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
const getNavigateToOptions = (
|
||||||
|
currentSearchString: string,
|
||||||
|
{ path, params, search }: AppNavigationTarget
|
||||||
|
) => {
|
||||||
|
const searchRecord = parseSearch(currentSearchString)
|
||||||
|
const updatedSearchRecord = search && search(searchRecord)
|
||||||
|
const updatedPath = path && generatePath(path, params)
|
||||||
|
return {
|
||||||
|
pathname: updatedPath,
|
||||||
|
search: updatedSearchRecord && stringifySearch(updatedSearchRecord)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useGetNavigateOptions = () => {
|
||||||
|
const location = useLocation()
|
||||||
|
const getToOptions = useCallback(
|
||||||
|
({ path, params, search }: AppNavigationTarget) => {
|
||||||
|
return getNavigateToOptions(location.search, { path, params, search })
|
||||||
|
},
|
||||||
|
[location.search]
|
||||||
|
)
|
||||||
|
return getToOptions
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useAppNavigate = () => {
|
||||||
|
const _navigate = useNavigate()
|
||||||
|
const getToOptions = useGetNavigateOptions()
|
||||||
|
const navigate = useCallback(
|
||||||
|
({
|
||||||
|
path,
|
||||||
|
params,
|
||||||
|
search,
|
||||||
|
...options
|
||||||
|
}: AppNavigationTarget & NavigateOptions) => {
|
||||||
|
return _navigate(getToOptions({ path, params, search }), options)
|
||||||
|
},
|
||||||
|
[getToOptions, _navigate]
|
||||||
|
)
|
||||||
|
return navigate
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AppNavigationLink = forwardRef<
|
||||||
|
HTMLAnchorElement | null,
|
||||||
|
AppNavigationTarget & Omit<LinkProps, 'to'>
|
||||||
|
>(({ path, params, search, ...options }, ref) => {
|
||||||
|
const getToOptions = useGetNavigateOptions()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link to={getToOptions({ path, params, search })} {...options} ref={ref} />
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
AppNavigationLink.displayName = 'AppNavigationLink'
|
@ -30,7 +30,7 @@ export const withPinnedHeader = (WrappedComponent, selector) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
componentWillUnmount() {
|
componentWillUnmount() {
|
||||||
this.observer && this.observer.unobserve(this.el);
|
this.observer?.unobserve(this.el);
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
|
@ -1,59 +0,0 @@
|
|||||||
import React, { createContext, useContext } from "react";
|
|
||||||
|
|
||||||
export function parseSiteFromDataset(dataset) {
|
|
||||||
const site = {
|
|
||||||
domain: dataset.domain,
|
|
||||||
offset: dataset.offset,
|
|
||||||
hasGoals: dataset.hasGoals === 'true',
|
|
||||||
hasProps: dataset.hasProps === 'true',
|
|
||||||
funnelsAvailable: dataset.funnelsAvailable === 'true',
|
|
||||||
propsAvailable: dataset.propsAvailable === 'true',
|
|
||||||
conversionsOptedOut: dataset.conversionsOptedOut === 'true',
|
|
||||||
funnelsOptedOut: dataset.funnelsOptedOut === 'true',
|
|
||||||
propsOptedOut: dataset.propsOptedOut === 'true',
|
|
||||||
revenueGoals: JSON.parse(dataset.revenueGoals),
|
|
||||||
funnels: JSON.parse(dataset.funnels),
|
|
||||||
statsBegin: dataset.statsBegin,
|
|
||||||
nativeStatsBegin: dataset.nativeStatsBegin,
|
|
||||||
embedded: dataset.embedded,
|
|
||||||
background: dataset.background,
|
|
||||||
isDbip: dataset.isDbip === 'true',
|
|
||||||
flags: JSON.parse(dataset.flags),
|
|
||||||
validIntervalsByPeriod: JSON.parse(dataset.validIntervalsByPeriod),
|
|
||||||
shared: !!dataset.sharedLinkAuth,
|
|
||||||
}
|
|
||||||
return site;
|
|
||||||
}
|
|
||||||
|
|
||||||
const siteContextDefaultValue = {
|
|
||||||
domain: '',
|
|
||||||
offset: '0',
|
|
||||||
hasGoals: false,
|
|
||||||
hasProps: false,
|
|
||||||
funnelsAvailable: false,
|
|
||||||
propsAvailable: false,
|
|
||||||
conversionsOptedOut: false,
|
|
||||||
funnelsOptedOut: false,
|
|
||||||
propsOptedOut: false,
|
|
||||||
revenueGoals: [],
|
|
||||||
funnels: [],
|
|
||||||
statsBegin: '',
|
|
||||||
nativeStatsBegin: '',
|
|
||||||
embedded: '',
|
|
||||||
background: '',
|
|
||||||
isDbip: false,
|
|
||||||
flags: {},
|
|
||||||
validIntervalsByPeriod: null,
|
|
||||||
shared: false,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
const SiteContext = createContext(siteContextDefaultValue)
|
|
||||||
|
|
||||||
export const useSiteContext = () => { return useContext(SiteContext) }
|
|
||||||
|
|
||||||
const SiteContextProvider = ({ site, children }) => {
|
|
||||||
return <SiteContext.Provider value={site}>{children}</SiteContext.Provider>
|
|
||||||
};
|
|
||||||
|
|
||||||
export default SiteContextProvider;
|
|
69
assets/js/dashboard/site-context.tsx
Normal file
69
assets/js/dashboard/site-context.tsx
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
/* @format */
|
||||||
|
import React, { createContext, ReactNode, useContext } from 'react'
|
||||||
|
|
||||||
|
export function parseSiteFromDataset(dataset: Record<string, string>) {
|
||||||
|
const site = {
|
||||||
|
domain: dataset.domain,
|
||||||
|
offset: dataset.offset,
|
||||||
|
hasGoals: dataset.hasGoals === 'true',
|
||||||
|
hasProps: dataset.hasProps === 'true',
|
||||||
|
funnelsAvailable: dataset.funnelsAvailable === 'true',
|
||||||
|
propsAvailable: dataset.propsAvailable === 'true',
|
||||||
|
conversionsOptedOut: dataset.conversionsOptedOut === 'true',
|
||||||
|
funnelsOptedOut: dataset.funnelsOptedOut === 'true',
|
||||||
|
propsOptedOut: dataset.propsOptedOut === 'true',
|
||||||
|
revenueGoals: JSON.parse(dataset.revenueGoals),
|
||||||
|
funnels: JSON.parse(dataset.funnels),
|
||||||
|
statsBegin: dataset.statsBegin,
|
||||||
|
nativeStatsBegin: dataset.nativeStatsBegin,
|
||||||
|
embedded: dataset.embedded,
|
||||||
|
background: dataset.background,
|
||||||
|
isDbip: dataset.isDbip === 'true',
|
||||||
|
flags: JSON.parse(dataset.flags),
|
||||||
|
validIntervalsByPeriod: JSON.parse(dataset.validIntervalsByPeriod),
|
||||||
|
shared: !!dataset.sharedLinkAuth
|
||||||
|
}
|
||||||
|
return site
|
||||||
|
}
|
||||||
|
|
||||||
|
const siteContextDefaultValue = {
|
||||||
|
domain: '',
|
||||||
|
offset: '0',
|
||||||
|
hasGoals: false,
|
||||||
|
hasProps: false,
|
||||||
|
funnelsAvailable: false,
|
||||||
|
propsAvailable: false,
|
||||||
|
conversionsOptedOut: false,
|
||||||
|
funnelsOptedOut: false,
|
||||||
|
propsOptedOut: false,
|
||||||
|
revenueGoals: [] as { event_name: string; currency: 'USD' }[],
|
||||||
|
funnels: [] as { id: number; name: string; steps_count: number }[],
|
||||||
|
statsBegin: '',
|
||||||
|
nativeStatsBegin: '',
|
||||||
|
embedded: '',
|
||||||
|
background: '',
|
||||||
|
isDbip: false,
|
||||||
|
flags: {},
|
||||||
|
validIntervalsByPeriod: {} as Record<string, Array<string>>,
|
||||||
|
shared: false
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PlausibleSite = typeof siteContextDefaultValue
|
||||||
|
|
||||||
|
const SiteContext = createContext(siteContextDefaultValue)
|
||||||
|
|
||||||
|
export const useSiteContext = () => {
|
||||||
|
return useContext(SiteContext)
|
||||||
|
}
|
||||||
|
|
||||||
|
const SiteContextProvider = ({
|
||||||
|
site,
|
||||||
|
children
|
||||||
|
}: {
|
||||||
|
site: PlausibleSite
|
||||||
|
children: ReactNode
|
||||||
|
}) => {
|
||||||
|
return <SiteContext.Provider value={site}>{children}</SiteContext.Provider>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SiteContextProvider
|
@ -16,6 +16,7 @@ import { useUserContext } from '../../user-context'
|
|||||||
/*global require*/
|
/*global require*/
|
||||||
function maybeRequire() {
|
function maybeRequire() {
|
||||||
if (BUILD_EXTRA) {
|
if (BUILD_EXTRA) {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||||
return require('../../extra/funnel')
|
return require('../../extra/funnel')
|
||||||
} else {
|
} else {
|
||||||
return { default: null }
|
return { default: null }
|
||||||
@ -65,6 +66,7 @@ export default function Behaviours({ importedDataInView }) {
|
|||||||
setShowingPropsForGoalFilter(true)
|
setShowingPropsForGoalFilter(true)
|
||||||
setMode(PROPS)
|
setMode(PROPS)
|
||||||
}
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -73,10 +75,12 @@ export default function Behaviours({ importedDataInView }) {
|
|||||||
setShowingPropsForGoalFilter(false)
|
setShowingPropsForGoalFilter(false)
|
||||||
setMode(CONVERSIONS)
|
setMode(CONVERSIONS)
|
||||||
}
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [hasGoalFilter(query)])
|
}, [hasGoalFilter(query)])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setMode(defaultMode())
|
setMode(defaultMode())
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [enabledModes])
|
}, [enabledModes])
|
||||||
|
|
||||||
useEffect(() => setLoading(true), [query, mode])
|
useEffect(() => setLoading(true), [query, mode])
|
||||||
|
@ -54,6 +54,7 @@ export default function Properties({ afterFetchData }) {
|
|||||||
|
|
||||||
setPropKeyLoading(false)
|
setPropKeyLoading(false)
|
||||||
})
|
})
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [query])
|
}, [query])
|
||||||
|
|
||||||
function getPropKeyFromStorage() {
|
function getPropKeyFromStorage() {
|
||||||
@ -73,6 +74,7 @@ export default function Properties({ afterFetchData }) {
|
|||||||
return (input) => {
|
return (input) => {
|
||||||
return api.get(url.apiPath(site, "/suggestions/prop_key"), query, { q: input.trim() })
|
return api.get(url.apiPath(site, "/suggestions/prop_key"), query, { q: input.trim() })
|
||||||
}
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [query])
|
}, [query])
|
||||||
|
|
||||||
function onPropKeySelect() {
|
function onPropKeySelect() {
|
||||||
|
@ -69,6 +69,7 @@ function subscribeKeybinding(element) {
|
|||||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||||
const handleKeyPress = useCallback((event) => {
|
const handleKeyPress = useCallback((event) => {
|
||||||
if (isKeyPressed(event, "i")) element.current?.click()
|
if (isKeyPressed(event, "i")) element.current?.click()
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
/* eslint-disable react-hooks/exhaustive-deps */
|
||||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
import * as api from '../../api';
|
import * as api from '../../api';
|
||||||
import * as storage from '../../util/storage';
|
import * as storage from '../../util/storage';
|
||||||
|
18
assets/js/dashboard/stats/locations/geolocation-notice.tsx
Normal file
18
assets/js/dashboard/stats/locations/geolocation-notice.tsx
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
/* @format */
|
||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
export const GeolocationNotice = () => {
|
||||||
|
return (
|
||||||
|
<div className="max-w-24 sm:max-w-none md:max-w-24 lg:max-w-none text-xs text-gray-500 absolute bottom-0 right-0">
|
||||||
|
IP Geolocation by{' '}
|
||||||
|
<a
|
||||||
|
target="_blank"
|
||||||
|
href="https://db-ip.com"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="text-indigo-600"
|
||||||
|
>
|
||||||
|
DB-IP
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
@ -200,7 +200,7 @@ export default class Locations extends React.Component {
|
|||||||
return <Countries onClick={this.onCountryFilter('countries')} site={this.props.site} query={this.props.query} afterFetchData={this.afterFetchData} />
|
return <Countries onClick={this.onCountryFilter('countries')} site={this.props.site} query={this.props.query} afterFetchData={this.afterFetchData} />
|
||||||
case "map":
|
case "map":
|
||||||
default:
|
default:
|
||||||
return <CountriesMap onClick={this.onCountryFilter('map')} site={this.props.site} query={this.props.query} afterFetchData={this.afterFetchData} />
|
return <CountriesMap onCountrySelect={this.onCountryFilter('map')} afterFetchData={this.afterFetchData} />
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
39
assets/js/dashboard/stats/locations/map-tooltip.tsx
Normal file
39
assets/js/dashboard/stats/locations/map-tooltip.tsx
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
/* @format */
|
||||||
|
import classNames from 'classnames'
|
||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
interface MapTooltipProps {
|
||||||
|
name: string
|
||||||
|
value: string
|
||||||
|
label: string
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MapTooltip = ({ name, value, label, x, y }: MapTooltipProps) => (
|
||||||
|
<div
|
||||||
|
className={classNames(
|
||||||
|
'absolute',
|
||||||
|
'z-50',
|
||||||
|
'p-2',
|
||||||
|
'translate-x-2',
|
||||||
|
'translate-y-2',
|
||||||
|
'pointer-events-none',
|
||||||
|
'rounded-sm',
|
||||||
|
'bg-white',
|
||||||
|
'dark:bg-gray-800',
|
||||||
|
'shadow',
|
||||||
|
'dark:border-gray-850',
|
||||||
|
'dark:text-gray-200',
|
||||||
|
'dark:shadow-gray-850',
|
||||||
|
'shadow-gray-200'
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
left: x,
|
||||||
|
top: y
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="font-semibold">{name}</div>
|
||||||
|
<strong className="dark:text-indigo-400">{value}</strong> {label}
|
||||||
|
</div>
|
||||||
|
)
|
@ -1,180 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import Datamap from 'datamaps'
|
|
||||||
import * as d3 from "d3"
|
|
||||||
|
|
||||||
import numberFormatter from '../../util/number-formatter'
|
|
||||||
import FadeIn from '../../fade-in'
|
|
||||||
import LazyLoader from '../../components/lazy-loader'
|
|
||||||
import MoreLink from '../more-link'
|
|
||||||
import * as api from '../../api'
|
|
||||||
import { navigateToQuery } from '../../query'
|
|
||||||
import { cleanLabels, replaceFilterByPrefix } from '../../util/filters';
|
|
||||||
import { countriesRoute } from '../../router';
|
|
||||||
import { useAppNavigate } from '../../navigation/use-app-navigate';
|
|
||||||
import { useQueryContext } from '../../query-context';
|
|
||||||
|
|
||||||
class Countries extends React.Component {
|
|
||||||
constructor(props) {
|
|
||||||
super(props)
|
|
||||||
this.resizeMap = this.resizeMap.bind(this)
|
|
||||||
this.drawMap = this.drawMap.bind(this)
|
|
||||||
this.getDataset = this.getDataset.bind(this)
|
|
||||||
this.state = {
|
|
||||||
loading: true,
|
|
||||||
darkTheme: document.querySelector('html').classList.contains('dark') || false
|
|
||||||
}
|
|
||||||
this.onVisible = this.onVisible.bind(this)
|
|
||||||
this.updateCountries = this.updateCountries.bind(this)
|
|
||||||
}
|
|
||||||
|
|
||||||
componentDidUpdate(prevProps) {
|
|
||||||
if (this.props.query !== prevProps.query) {
|
|
||||||
// eslint-disable-next-line react/no-did-update-set-state
|
|
||||||
this.setState({ loading: true, countries: null })
|
|
||||||
this.fetchCountries(this.drawMap)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
componentWillUnmount() {
|
|
||||||
window.removeEventListener('resize', this.resizeMap);
|
|
||||||
document.removeEventListener('tick', this.updateCountries);
|
|
||||||
}
|
|
||||||
|
|
||||||
onVisible() {
|
|
||||||
this.fetchCountries(this.drawMap)
|
|
||||||
window.addEventListener('resize', this.resizeMap);
|
|
||||||
if (this.props.query.period === 'realtime') {
|
|
||||||
document.addEventListener('tick', this.updateCountries)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
getDataset() {
|
|
||||||
const dataset = {};
|
|
||||||
|
|
||||||
var onlyValues = this.state.countries.map(function(obj) { return obj.visitors });
|
|
||||||
var maxValue = Math.max.apply(null, onlyValues);
|
|
||||||
|
|
||||||
// eslint-disable-next-line no-undef
|
|
||||||
const paletteScale = d3.scale.linear()
|
|
||||||
.domain([0, maxValue])
|
|
||||||
.range([
|
|
||||||
this.state.darkTheme ? "#2e3954" : "#f3ebff",
|
|
||||||
this.state.darkTheme ? "#6366f1" : "#a779e9"
|
|
||||||
])
|
|
||||||
|
|
||||||
this.state.countries.forEach(function(item) {
|
|
||||||
dataset[item.alpha_3] = { numberOfThings: item.visitors, fillColor: paletteScale(item.visitors) };
|
|
||||||
});
|
|
||||||
|
|
||||||
return dataset
|
|
||||||
}
|
|
||||||
|
|
||||||
updateCountries() {
|
|
||||||
this.fetchCountries(() => { this.map.updateChoropleth(this.getDataset(), { reset: true }) })
|
|
||||||
}
|
|
||||||
|
|
||||||
fetchCountries(cb) {
|
|
||||||
return api.get(`/api/stats/${encodeURIComponent(this.props.site.domain)}/countries`, this.props.query, { limit: 300 })
|
|
||||||
.then((response) => {
|
|
||||||
if (this.props.afterFetchData) {
|
|
||||||
this.props.afterFetchData(response)
|
|
||||||
}
|
|
||||||
|
|
||||||
this.setState({ loading: false, countries: response.results }, cb)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
resizeMap() {
|
|
||||||
this.map && this.map.resize()
|
|
||||||
}
|
|
||||||
|
|
||||||
drawMap() {
|
|
||||||
const dataset = this.getDataset();
|
|
||||||
const label = this.props.query.period === 'realtime' ? 'Current visitors' : 'Visitors'
|
|
||||||
const defaultFill = this.state.darkTheme ? '#2d3747' : '#f8fafc'
|
|
||||||
const highlightFill = this.state.darkTheme ? '#374151' : '#F5F5F5'
|
|
||||||
const borderColor = this.state.darkTheme ? '#1f2937' : '#dae1e7'
|
|
||||||
const highlightBorderColor = this.state.darkTheme ? '#4f46e5' : '#a779e9'
|
|
||||||
|
|
||||||
this.map = new Datamap({
|
|
||||||
element: document.getElementById('map-container'),
|
|
||||||
responsive: true,
|
|
||||||
projection: 'mercator',
|
|
||||||
fills: { defaultFill },
|
|
||||||
data: dataset,
|
|
||||||
geographyConfig: {
|
|
||||||
borderColor,
|
|
||||||
highlightBorderWidth: 2,
|
|
||||||
highlightFillColor: (geo) => geo.fillColor || highlightFill,
|
|
||||||
highlightBorderColor,
|
|
||||||
popupTemplate: (geo, data) => {
|
|
||||||
if (!data) { return null; }
|
|
||||||
const pluralizedLabel = data.numberOfThings === 1 ? label.slice(0, -1) : label
|
|
||||||
return ['<div class="hoverinfo dark:bg-gray-800 dark:shadow-gray-850 dark:border-gray-850 dark:text-gray-200">',
|
|
||||||
'<strong>', geo.properties.name, ' </strong>',
|
|
||||||
'<br><strong class="dark:text-indigo-400">', numberFormatter(data.numberOfThings), '</strong> ', pluralizedLabel,
|
|
||||||
'</div>'].join('');
|
|
||||||
}
|
|
||||||
},
|
|
||||||
done: (datamap) => {
|
|
||||||
datamap.svg.selectAll('.datamaps-subunit').on('click', (geography) => {
|
|
||||||
const country = this.state.countries.find(c => c.alpha_3 === geography.id)
|
|
||||||
const filters = replaceFilterByPrefix(this.props.query, "country", ["is", "country", [country.code]])
|
|
||||||
const labels = cleanLabels(filters, this.props.query.labels, "country", { [country.code]: country.name })
|
|
||||||
|
|
||||||
if (country) {
|
|
||||||
this.props.onClick()
|
|
||||||
|
|
||||||
navigateToQuery(
|
|
||||||
this.props.navigate,
|
|
||||||
this.props.query,
|
|
||||||
{
|
|
||||||
filters,
|
|
||||||
labels
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
})
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
geolocationDbNotice() {
|
|
||||||
if (this.props.site.isDbip) {
|
|
||||||
return (
|
|
||||||
<span className="text-xs text-gray-500 absolute bottom-4 right-3">IP Geolocation by <a target="_blank" href="https://db-ip.com" rel="noreferrer" className="text-indigo-600">DB-IP</a></span>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
renderBody() {
|
|
||||||
if (this.state.countries) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="mx-auto mt-4" style={{ width: '100%', maxWidth: '475px', height: '335px' }} id="map-container"></div>
|
|
||||||
<MoreLink list={this.state.countries} linkProps={{ path: countriesRoute.path, search: (search) => search }} />
|
|
||||||
{this.geolocationDbNotice()}
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
return (
|
|
||||||
<LazyLoader onVisible={this.onVisible}>
|
|
||||||
{this.state.loading && <div className="mx-auto my-32 loading"><div></div></div>}
|
|
||||||
<FadeIn show={!this.state.loading}>
|
|
||||||
{this.renderBody()}
|
|
||||||
</FadeIn>
|
|
||||||
</LazyLoader>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function CountriesWithRouter(props) {const {query} = useQueryContext(); const navigate = useAppNavigate();
|
|
||||||
return (<Countries {...props} query={query} navigate={navigate}/>)}
|
|
329
assets/js/dashboard/stats/locations/map.tsx
Normal file
329
assets/js/dashboard/stats/locations/map.tsx
Normal file
@ -0,0 +1,329 @@
|
|||||||
|
/* @format */
|
||||||
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
import * as d3 from 'd3'
|
||||||
|
import classNames from 'classnames'
|
||||||
|
// @ts-expect-error untyped
|
||||||
|
import * as api from '../../api'
|
||||||
|
// @ts-expect-error untyped
|
||||||
|
import { navigateToQuery } from '../../query'
|
||||||
|
// @ts-expect-error untyped
|
||||||
|
import { replaceFilterByPrefix, cleanLabels } from '../../util/filters'
|
||||||
|
import { useAppNavigate } from '../../navigation/use-app-navigate'
|
||||||
|
// @ts-expect-error untyped
|
||||||
|
import numberFormatter from '../../util/number-formatter'
|
||||||
|
import * as topojson from 'topojson-client'
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { useSiteContext } from '../../site-context'
|
||||||
|
// @ts-expect-error untyped
|
||||||
|
import { useQueryContext } from '../../query-context'
|
||||||
|
import worldJson from 'visionscarto-world-atlas/world/110m.json'
|
||||||
|
import { UIMode, useTheme } from '../../theme-context'
|
||||||
|
import { apiPath } from '../../util/url'
|
||||||
|
// @ts-expect-error untyped
|
||||||
|
import MoreLink from '../more-link'
|
||||||
|
// @ts-expect-error untyped
|
||||||
|
import { countriesRoute } from '../../router'
|
||||||
|
// @ts-expect-error untyped
|
||||||
|
import { MIN_HEIGHT } from '../reports/list'
|
||||||
|
import { MapTooltip } from './map-tooltip'
|
||||||
|
import { GeolocationNotice } from './geolocation-notice'
|
||||||
|
|
||||||
|
const width = 475
|
||||||
|
const height = 335
|
||||||
|
|
||||||
|
type CountryData = {
|
||||||
|
alpha_3: string
|
||||||
|
name: string
|
||||||
|
visitors: number
|
||||||
|
code: string
|
||||||
|
}
|
||||||
|
type WorldJsonCountryData = { properties: { name: string; a3: string } }
|
||||||
|
|
||||||
|
const WorldMap = ({
|
||||||
|
onCountrySelect,
|
||||||
|
afterFetchData
|
||||||
|
}: {
|
||||||
|
onCountrySelect: () => void
|
||||||
|
afterFetchData: (response: unknown) => void
|
||||||
|
}) => {
|
||||||
|
const navigate = useAppNavigate()
|
||||||
|
const { mode } = useTheme()
|
||||||
|
const site = useSiteContext()
|
||||||
|
const { query } = useQueryContext()
|
||||||
|
const svgRef = useRef<SVGSVGElement | null>(null)
|
||||||
|
const [tooltip, setTooltip] = useState<{
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
hoveredCountryAlpha3Code: string | null
|
||||||
|
}>({ x: 0, y: 0, hoveredCountryAlpha3Code: null })
|
||||||
|
|
||||||
|
const labels =
|
||||||
|
query.period === 'realtime'
|
||||||
|
? { singular: 'Current visitor', plural: 'Current visitors' }
|
||||||
|
: { singular: 'Visitor', plural: 'Visitors' }
|
||||||
|
|
||||||
|
const { data, refetch, isFetching, isError } = useQuery({
|
||||||
|
queryKey: ['countries', 'map', query],
|
||||||
|
placeholderData: (previousData) => previousData,
|
||||||
|
queryFn: async (): Promise<{
|
||||||
|
results: CountryData[]
|
||||||
|
}> => {
|
||||||
|
return await api.get(apiPath(site, '/countries'), query, {
|
||||||
|
limit: 300
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onTickRefetchData = () => {
|
||||||
|
if (query.period === 'realtime') {
|
||||||
|
refetch()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener('tick', onTickRefetchData)
|
||||||
|
return () => document.removeEventListener('tick', onTickRefetchData)
|
||||||
|
}, [query.period, refetch])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (data) {
|
||||||
|
afterFetchData(data)
|
||||||
|
}
|
||||||
|
}, [afterFetchData, data])
|
||||||
|
|
||||||
|
const { maxValue, dataByCountryCode } = useMemo(() => {
|
||||||
|
const dataByCountryCode: Map<string, CountryData> = new Map()
|
||||||
|
let maxValue = 0
|
||||||
|
for (const { alpha_3, visitors, name, code } of data?.results || []) {
|
||||||
|
if (visitors > maxValue) {
|
||||||
|
maxValue = visitors
|
||||||
|
}
|
||||||
|
dataByCountryCode.set(alpha_3, { alpha_3, visitors, name, code })
|
||||||
|
}
|
||||||
|
return { maxValue, dataByCountryCode }
|
||||||
|
}, [data])
|
||||||
|
|
||||||
|
const onCountryClick = useCallback(
|
||||||
|
(d: WorldJsonCountryData) => {
|
||||||
|
const country = dataByCountryCode.get(d.properties.a3)
|
||||||
|
const clickable = country && country.visitors
|
||||||
|
if (clickable) {
|
||||||
|
const filters = replaceFilterByPrefix(query, 'country', [
|
||||||
|
'is',
|
||||||
|
'country',
|
||||||
|
[country.code]
|
||||||
|
])
|
||||||
|
const labels = cleanLabels(filters, query.labels, 'country', {
|
||||||
|
[country.code]: country.name
|
||||||
|
})
|
||||||
|
onCountrySelect()
|
||||||
|
navigateToQuery(navigate, query, { filters, labels })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[navigate, query, dataByCountryCode, onCountrySelect]
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!svgRef.current) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const svg = drawInteractiveCountries(svgRef.current, setTooltip)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
svg.selectAll('*').remove()
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (svgRef.current) {
|
||||||
|
const palette = colorScales[mode]
|
||||||
|
|
||||||
|
const getColorForValue = d3
|
||||||
|
.scaleLinear<string>()
|
||||||
|
.domain([0, maxValue])
|
||||||
|
.range(palette)
|
||||||
|
|
||||||
|
colorInCountriesWithValues(
|
||||||
|
svgRef.current,
|
||||||
|
getColorForValue,
|
||||||
|
dataByCountryCode
|
||||||
|
).on('click', (_event, countryPath) => {
|
||||||
|
onCountryClick(countryPath as unknown as WorldJsonCountryData)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [mode, maxValue, dataByCountryCode, onCountryClick])
|
||||||
|
|
||||||
|
const hoveredCountryData = tooltip.hoveredCountryAlpha3Code
|
||||||
|
? dataByCountryCode.get(tooltip.hoveredCountryAlpha3Code)
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col relative" style={{ minHeight: MIN_HEIGHT }}>
|
||||||
|
<div className="mt-4" />
|
||||||
|
<div
|
||||||
|
className="relative mx-auto w-full"
|
||||||
|
style={{ height: height, maxWidth: width }}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
ref={svgRef}
|
||||||
|
viewBox={`0 0 ${width} ${height}`}
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
{!!hoveredCountryData && (
|
||||||
|
<MapTooltip
|
||||||
|
x={tooltip.x}
|
||||||
|
y={tooltip.y}
|
||||||
|
name={hoveredCountryData.name}
|
||||||
|
value={numberFormatter(hoveredCountryData.visitors)}
|
||||||
|
label={
|
||||||
|
labels[hoveredCountryData.visitors === 1 ? 'singular' : 'plural']
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{isFetching ||
|
||||||
|
(isError && (
|
||||||
|
<div className="absolute inset-0 flex justify-center items-center">
|
||||||
|
<div className="loading">
|
||||||
|
<div />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<MoreLink
|
||||||
|
list={data?.results ?? []}
|
||||||
|
linkProps={{
|
||||||
|
path: countriesRoute.path,
|
||||||
|
// @ts-expect-error MoreLink not typed yet
|
||||||
|
search: (search) => search
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{site.isDbip && <GeolocationNotice />}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const colorScales = {
|
||||||
|
[UIMode.dark]: ['#2e3954', '#6366f1'],
|
||||||
|
[UIMode.light]: ['#f3ebff', '#a779e9']
|
||||||
|
}
|
||||||
|
|
||||||
|
const sharedCountryClass = classNames('transition-colors')
|
||||||
|
|
||||||
|
const countryClass = classNames(
|
||||||
|
sharedCountryClass,
|
||||||
|
'stroke-1',
|
||||||
|
'fill-[#f8fafc]',
|
||||||
|
'stroke-[#dae1e7]',
|
||||||
|
'dark:fill-[#2d3747]',
|
||||||
|
'dark:stroke-[#1f2937]'
|
||||||
|
)
|
||||||
|
|
||||||
|
const highlightedCountryClass = classNames(
|
||||||
|
sharedCountryClass,
|
||||||
|
'stroke-2',
|
||||||
|
'fill-[#f5f5f5]',
|
||||||
|
'stroke-[#a779e9]',
|
||||||
|
'dark:fill-[#374151]',
|
||||||
|
'dark:stroke-[#4f46e5]'
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Used to color the countries
|
||||||
|
* @returns the svg elements represeting countries
|
||||||
|
*/
|
||||||
|
function colorInCountriesWithValues(
|
||||||
|
element: SVGSVGElement,
|
||||||
|
getColorForValue: d3.ScaleLinear<string, string, never>,
|
||||||
|
dataByCountryCode: Map<string, CountryData>
|
||||||
|
) {
|
||||||
|
function getCountryByCountryPath(countryPath: unknown) {
|
||||||
|
return dataByCountryCode.get(
|
||||||
|
(countryPath as unknown as WorldJsonCountryData).properties.a3
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const svg = d3.select(element)
|
||||||
|
|
||||||
|
return svg
|
||||||
|
.selectAll('path')
|
||||||
|
.style('fill', (countryPath) => {
|
||||||
|
const country = getCountryByCountryPath(countryPath)
|
||||||
|
if (!country?.visitors) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return getColorForValue(country.visitors)
|
||||||
|
})
|
||||||
|
.style('cursor', (countryPath) => {
|
||||||
|
const country = getCountryByCountryPath(countryPath)
|
||||||
|
if (!country?.visitors) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return 'pointer'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @returns the d3 selected svg element */
|
||||||
|
function drawInteractiveCountries(
|
||||||
|
element: SVGSVGElement,
|
||||||
|
setTooltip: React.Dispatch<
|
||||||
|
React.SetStateAction<{
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
hoveredCountryAlpha3Code: string | null
|
||||||
|
}>
|
||||||
|
>
|
||||||
|
) {
|
||||||
|
const path = setupProjetionPath()
|
||||||
|
const data = parseWorldTopoJsonToGeoJsonFeatures()
|
||||||
|
const svg = d3.select(element)
|
||||||
|
|
||||||
|
svg
|
||||||
|
.selectAll('path')
|
||||||
|
.data(data)
|
||||||
|
.enter()
|
||||||
|
.append('path')
|
||||||
|
.attr('class', countryClass)
|
||||||
|
.attr('d', path as never)
|
||||||
|
|
||||||
|
.on('mouseover', function (event, country) {
|
||||||
|
const [x, y] = d3.pointer(event, svg.node()?.parentNode)
|
||||||
|
setTooltip({ x, y, hoveredCountryAlpha3Code: country.properties.a3 })
|
||||||
|
// brings country to front
|
||||||
|
this.parentNode?.appendChild(this)
|
||||||
|
d3.select(this).attr('class', highlightedCountryClass)
|
||||||
|
})
|
||||||
|
|
||||||
|
.on('mousemove', function (event) {
|
||||||
|
const [x, y] = d3.pointer(event, svg.node()?.parentNode)
|
||||||
|
setTooltip((currentState) => ({ ...currentState, x, y }))
|
||||||
|
})
|
||||||
|
|
||||||
|
.on('mouseout', function () {
|
||||||
|
setTooltip({ x: 0, y: 0, hoveredCountryAlpha3Code: null })
|
||||||
|
d3.select(this).attr('class', countryClass)
|
||||||
|
})
|
||||||
|
|
||||||
|
return svg
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupProjetionPath() {
|
||||||
|
const projection = d3
|
||||||
|
.geoMercator()
|
||||||
|
.scale(75)
|
||||||
|
.translate([width / 2, height / 1.5])
|
||||||
|
|
||||||
|
const path = d3.geoPath().projection(projection)
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseWorldTopoJsonToGeoJsonFeatures(): Array<WorldJsonCountryData> {
|
||||||
|
const collection = topojson.feature(
|
||||||
|
// @ts-expect-error strings in worldJson not recongizable as the enum values declared in library
|
||||||
|
worldJson,
|
||||||
|
worldJson.objects.countries
|
||||||
|
)
|
||||||
|
// @ts-expect-error topojson.feature return type incorrectly inferred as not a collection
|
||||||
|
return collection.features
|
||||||
|
}
|
||||||
|
|
||||||
|
export default WorldMap
|
@ -19,6 +19,7 @@ export default function FilterModalRow({
|
|||||||
|
|
||||||
const selectedClauses = useMemo(
|
const selectedClauses = useMemo(
|
||||||
() => clauses.map((value) => ({ value, label: getLabel(labels, filterKey, value) })),
|
() => clauses.map((value) => ({ value, label: getLabel(labels, filterKey, value) })),
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
[filter, labels]
|
[filter, labels]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -14,7 +14,6 @@ function detailsIcon() {
|
|||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
strokeLinejoin="round"
|
strokeLinejoin="round"
|
||||||
>
|
>
|
||||||
{/* eslint-disable-next-line max-len */}
|
|
||||||
<path d="M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3" />
|
<path d="M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3" />
|
||||||
</svg>
|
</svg>
|
||||||
)
|
)
|
||||||
@ -26,7 +25,6 @@ export default function MoreLink({ linkProps, list, className, onClick }) {
|
|||||||
<div className={`w-full text-center ${className ? className : ''}`}>
|
<div className={`w-full text-center ${className ? className : ''}`}>
|
||||||
<AppNavigationLink
|
<AppNavigationLink
|
||||||
{...linkProps}
|
{...linkProps}
|
||||||
// eslint-disable-next-line max-len
|
|
||||||
className="leading-snug font-bold text-sm text-gray-500 dark:text-gray-400 hover:text-red-500 dark:hover:text-red-400 transition tracking-wide"
|
className="leading-snug font-bold text-sm text-gray-500 dark:text-gray-400 hover:text-red-500 dark:hover:text-red-400 transition tracking-wide"
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
>
|
>
|
||||||
|
@ -125,6 +125,7 @@ export default function ListReport({ keyLabel, metrics, colMinWidth = COL_MIN_WI
|
|||||||
|
|
||||||
setState({ loading: false, list: response.results })
|
setState({ loading: false, list: response.results })
|
||||||
})
|
})
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [keyLabel, query])
|
}, [keyLabel, query])
|
||||||
|
|
||||||
const onVisible = () => { setVisible(true) }
|
const onVisible = () => { setVisible(true) }
|
||||||
@ -136,6 +137,7 @@ export default function ListReport({ keyLabel, metrics, colMinWidth = COL_MIN_WI
|
|||||||
// only read the new metrics once the new list is loaded.
|
// only read the new metrics once the new list is loaded.
|
||||||
setState({ loading: true, list: null })
|
setState({ loading: true, list: null })
|
||||||
}
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [goalFilterApplied]);
|
}, [goalFilterApplied]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -145,6 +147,7 @@ export default function ListReport({ keyLabel, metrics, colMinWidth = COL_MIN_WI
|
|||||||
}
|
}
|
||||||
|
|
||||||
return () => { document.removeEventListener('tick', getData) }
|
return () => { document.removeEventListener('tick', getData) }
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [keyLabel, query, visible]);
|
}, [keyLabel, query, visible]);
|
||||||
|
|
||||||
// returns a filtered `metrics` list. Since currently, the backend can return different
|
// returns a filtered `metrics` list. Since currently, the backend can return different
|
||||||
|
@ -6,6 +6,7 @@ import React from "react"
|
|||||||
/*global require*/
|
/*global require*/
|
||||||
function maybeRequire() {
|
function maybeRequire() {
|
||||||
if (BUILD_EXTRA) {
|
if (BUILD_EXTRA) {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||||
return require('../../extra/money')
|
return require('../../extra/money')
|
||||||
} else {
|
} else {
|
||||||
return { default: null }
|
return { default: null }
|
||||||
|
62
assets/js/dashboard/theme-context.tsx
Normal file
62
assets/js/dashboard/theme-context.tsx
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
/* @format */
|
||||||
|
import React, {
|
||||||
|
createContext,
|
||||||
|
ReactNode,
|
||||||
|
useContext,
|
||||||
|
useLayoutEffect,
|
||||||
|
useRef,
|
||||||
|
useState
|
||||||
|
} from 'react'
|
||||||
|
|
||||||
|
export enum UIMode {
|
||||||
|
light = 'light',
|
||||||
|
dark = 'dark'
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultValue = { mode: UIMode.light }
|
||||||
|
|
||||||
|
const ThemeContext = createContext(defaultValue)
|
||||||
|
|
||||||
|
function parseUIMode(element: Element | null): UIMode {
|
||||||
|
return element?.classList.contains('dark') ? UIMode.dark : UIMode.light
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ThemeContextProvider({
|
||||||
|
children
|
||||||
|
}: {
|
||||||
|
children: ReactNode
|
||||||
|
}) {
|
||||||
|
const observerRef = useRef<MutationObserver | null>(null)
|
||||||
|
const [mode, setMode] = useState<UIMode>(
|
||||||
|
parseUIMode(document.querySelector('html'))
|
||||||
|
)
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const htmlElement = document.querySelector('html')
|
||||||
|
const currentObserver = observerRef.current
|
||||||
|
if (htmlElement && !currentObserver) {
|
||||||
|
const observer = new MutationObserver((mutations) => {
|
||||||
|
mutations.forEach((mutation) => {
|
||||||
|
if (
|
||||||
|
mutation.type === 'attributes' &&
|
||||||
|
mutation.attributeName === 'class'
|
||||||
|
) {
|
||||||
|
return setMode(parseUIMode(mutation.target as Element))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
observerRef.current = observer
|
||||||
|
observer.observe(htmlElement, {
|
||||||
|
attributes: true,
|
||||||
|
attributeFilter: ['class']
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return () => currentObserver?.disconnect()
|
||||||
|
}, [])
|
||||||
|
return (
|
||||||
|
<ThemeContext.Provider value={{ mode }}>{children}</ThemeContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTheme() {
|
||||||
|
return useContext(ThemeContext)
|
||||||
|
}
|
@ -1,14 +0,0 @@
|
|||||||
import React, { createContext, useContext } from "react";
|
|
||||||
|
|
||||||
const userContextDefaultValue = {
|
|
||||||
role: '',
|
|
||||||
loggedIn: false,
|
|
||||||
}
|
|
||||||
|
|
||||||
const UserContext = createContext(userContextDefaultValue)
|
|
||||||
|
|
||||||
export const useUserContext = () => { return useContext(UserContext) }
|
|
||||||
|
|
||||||
export default function UserContextProvider({ role, loggedIn, children }) {
|
|
||||||
return <UserContext.Provider value={{role, loggedIn}}>{children}</UserContext.Provider>
|
|
||||||
};
|
|
35
assets/js/dashboard/user-context.tsx
Normal file
35
assets/js/dashboard/user-context.tsx
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
/* @format */
|
||||||
|
import React, { createContext, ReactNode, useContext } from 'react'
|
||||||
|
|
||||||
|
export enum Role {
|
||||||
|
owner = 'owner',
|
||||||
|
admin = 'admin',
|
||||||
|
viewer = 'viewer'
|
||||||
|
}
|
||||||
|
|
||||||
|
const userContextDefaultValue = {
|
||||||
|
role: Role.viewer,
|
||||||
|
loggedIn: false
|
||||||
|
}
|
||||||
|
|
||||||
|
const UserContext = createContext(userContextDefaultValue)
|
||||||
|
|
||||||
|
export const useUserContext = () => {
|
||||||
|
return useContext(UserContext)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function UserContextProvider({
|
||||||
|
role,
|
||||||
|
loggedIn,
|
||||||
|
children
|
||||||
|
}: {
|
||||||
|
role: Role
|
||||||
|
loggedIn: boolean
|
||||||
|
children: ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<UserContext.Provider value={{ role, loggedIn }}>
|
||||||
|
{children}
|
||||||
|
</UserContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
@ -11,7 +11,7 @@ function testLocalStorageAvailability(){
|
|||||||
localStorage.setItem(testItem, testItem);
|
localStorage.setItem(testItem, testItem);
|
||||||
localStorage.removeItem(testItem);
|
localStorage.removeItem(testItem);
|
||||||
return true;
|
return true;
|
||||||
} catch(e) {
|
} catch (_e) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,129 +0,0 @@
|
|||||||
import JsonURL from '@jsonurl/jsonurl'
|
|
||||||
|
|
||||||
export function apiPath(site, path = '') {
|
|
||||||
return `/api/stats/${encodeURIComponent(site.domain)}${path}/`
|
|
||||||
}
|
|
||||||
|
|
||||||
export function externalLinkForPage(domain, page) {
|
|
||||||
const domainURL = new URL(`https://${domain}`)
|
|
||||||
return `https://${domainURL.host}${page}`
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isValidHttpUrl(string) {
|
|
||||||
let url;
|
|
||||||
|
|
||||||
try {
|
|
||||||
url = new URL(string);
|
|
||||||
} catch (_) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return url.protocol === "http:" || url.protocol === "https:";
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
export function trimURL(url, maxLength) {
|
|
||||||
if (url.length <= maxLength) {
|
|
||||||
return url;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ellipsis = "...";
|
|
||||||
|
|
||||||
if (isValidHttpUrl(url)) {
|
|
||||||
const [protocol, restURL] = url.split('://');
|
|
||||||
const parts = restURL.split('/');
|
|
||||||
|
|
||||||
const host = parts.shift();
|
|
||||||
if (host.length > maxLength - 5) {
|
|
||||||
return `${protocol}://${host.substr(0, maxLength - 5)}${ellipsis}${restURL.slice(-maxLength + 5)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
let remainingLength = maxLength - host.length - 5;
|
|
||||||
let trimmedURL = `${protocol}://${host}`;
|
|
||||||
|
|
||||||
for (const part of parts) {
|
|
||||||
if (part.length <= remainingLength) {
|
|
||||||
trimmedURL += '/' + part;
|
|
||||||
remainingLength -= part.length + 1;
|
|
||||||
} else {
|
|
||||||
const startTrim = Math.floor((remainingLength - 3) / 2);
|
|
||||||
const endTrim = Math.ceil((remainingLength - 3) / 2);
|
|
||||||
trimmedURL += `/${part.substr(0, startTrim)}...${part.slice(-endTrim)}`;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return trimmedURL;
|
|
||||||
} else {
|
|
||||||
const leftSideLength = Math.floor(maxLength / 2);
|
|
||||||
const rightSideLength = maxLength - leftSideLength;
|
|
||||||
|
|
||||||
const leftSide = url.slice(0, leftSideLength);
|
|
||||||
const rightSide = url.slice(-rightSideLength);
|
|
||||||
|
|
||||||
return leftSide + ellipsis + rightSide;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {String} input - value to encode for URI
|
|
||||||
* @returns {String} value encoded for URI
|
|
||||||
*/
|
|
||||||
export function encodeURIComponentPermissive(input) {
|
|
||||||
return encodeURIComponent(input)
|
|
||||||
.replaceAll("%2C", ",")
|
|
||||||
.replaceAll("%3A", ":")
|
|
||||||
.replaceAll("%2F", "/")
|
|
||||||
}
|
|
||||||
|
|
||||||
export function encodeSearchParamEntries([k, v]) {
|
|
||||||
return `${encodeURIComponentPermissive(k)}=${encodeURIComponentPermissive(v)}`
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isSearchEntryDefined([_key, value]) {
|
|
||||||
return value !== undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
export function stringifySearch(searchRecord) {
|
|
||||||
const definedSearchEntries = Object.entries(searchRecord || {}).map(stringifySearchEntry).filter(isSearchEntryDefined)
|
|
||||||
|
|
||||||
const encodedSearchEntries = definedSearchEntries.map(encodeSearchParamEntries)
|
|
||||||
|
|
||||||
return encodedSearchEntries.length ? `?${encodedSearchEntries.join('&')}` : ''
|
|
||||||
}
|
|
||||||
|
|
||||||
export function stringifySearchEntry([key, value]) {
|
|
||||||
const isEmptyObjectOrArray = typeof value === 'object' && value !== null && Object.entries(value).length === 0;
|
|
||||||
if (
|
|
||||||
value === undefined ||
|
|
||||||
value === null ||
|
|
||||||
isEmptyObjectOrArray
|
|
||||||
) {
|
|
||||||
return [key, undefined]
|
|
||||||
}
|
|
||||||
|
|
||||||
return [key, JsonURL.stringify(value)]
|
|
||||||
}
|
|
||||||
|
|
||||||
export function parseSearchFragment(searchStringFragment) {
|
|
||||||
if (searchStringFragment === '') {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
const fragmentWithReEncodedSymbols =
|
|
||||||
searchStringFragment
|
|
||||||
.replaceAll('=',encodeURIComponent('='))
|
|
||||||
.replaceAll('#',encodeURIComponent('#'))
|
|
||||||
try {
|
|
||||||
return JsonURL.parse(fragmentWithReEncodedSymbols)
|
|
||||||
} catch (e) {
|
|
||||||
console.error(`Failed to parse URL fragment ${fragmentWithReEncodedSymbols}`)
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function parseSearch(searchString) {
|
|
||||||
const urlSearchParams = new URLSearchParams(searchString);
|
|
||||||
const searchRecord = {};
|
|
||||||
urlSearchParams.forEach((v, k) => searchRecord[k] = parseSearchFragment(v))
|
|
||||||
return searchRecord;
|
|
||||||
}
|
|
149
assets/js/dashboard/util/url.ts
Normal file
149
assets/js/dashboard/util/url.ts
Normal file
@ -0,0 +1,149 @@
|
|||||||
|
/* @format */
|
||||||
|
import JsonURL from '@jsonurl/jsonurl'
|
||||||
|
import { PlausibleSite } from '../site-context'
|
||||||
|
|
||||||
|
export function apiPath(site: PlausibleSite, path = ''): string {
|
||||||
|
return `/api/stats/${encodeURIComponent(site.domain)}${path}/`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function externalLinkForPage(
|
||||||
|
domain: PlausibleSite['domain'],
|
||||||
|
page: string
|
||||||
|
): string {
|
||||||
|
const domainURL = new URL(`https://${domain}`)
|
||||||
|
return `https://${domainURL.host}${page}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isValidHttpUrl(input: string): boolean {
|
||||||
|
let url
|
||||||
|
|
||||||
|
try {
|
||||||
|
url = new URL(input)
|
||||||
|
} catch (_) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return url.protocol === 'http:' || url.protocol === 'https:'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function trimURL(url: string, maxLength: number): string {
|
||||||
|
if (url.length <= maxLength) {
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
|
||||||
|
const ellipsis = '...'
|
||||||
|
|
||||||
|
if (isValidHttpUrl(url)) {
|
||||||
|
const [protocol, restURL] = url.split('://')
|
||||||
|
const parts = restURL.split('/')
|
||||||
|
|
||||||
|
const host = parts.shift() || ''
|
||||||
|
if (host.length > maxLength - 5) {
|
||||||
|
return `${protocol}://${host.substr(0, maxLength - 5)}${ellipsis}${restURL.slice(-maxLength + 5)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
let remainingLength = maxLength - host.length - 5
|
||||||
|
let trimmedURL = `${protocol}://${host}`
|
||||||
|
|
||||||
|
for (const part of parts) {
|
||||||
|
if (part.length <= remainingLength) {
|
||||||
|
trimmedURL += '/' + part
|
||||||
|
remainingLength -= part.length + 1
|
||||||
|
} else {
|
||||||
|
const startTrim = Math.floor((remainingLength - 3) / 2)
|
||||||
|
const endTrim = Math.ceil((remainingLength - 3) / 2)
|
||||||
|
trimmedURL += `/${part.substr(0, startTrim)}...${part.slice(-endTrim)}`
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return trimmedURL
|
||||||
|
} else {
|
||||||
|
const leftSideLength = Math.floor(maxLength / 2)
|
||||||
|
const rightSideLength = maxLength - leftSideLength
|
||||||
|
|
||||||
|
const leftSide = url.slice(0, leftSideLength)
|
||||||
|
const rightSide = url.slice(-rightSideLength)
|
||||||
|
|
||||||
|
return leftSide + ellipsis + rightSide
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function encodeURIComponentPermissive(input: string): string {
|
||||||
|
return (
|
||||||
|
encodeURIComponent(input)
|
||||||
|
/* @ts-expect-error API supposedly not present in compilation target */
|
||||||
|
.replaceAll('%2C', ',')
|
||||||
|
.replaceAll('%3A', ':')
|
||||||
|
.replaceAll('%2F', '/')
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function encodeSearchParamEntries([k, v]: [string, string]): string {
|
||||||
|
return `${encodeURIComponentPermissive(k)}=${encodeURIComponentPermissive(v)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isSearchEntryDefined(
|
||||||
|
entry: [string, undefined | string]
|
||||||
|
): entry is [string, string] {
|
||||||
|
return entry[1] !== undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stringifySearch(
|
||||||
|
searchRecord: Record<string, unknown>
|
||||||
|
): '' | string {
|
||||||
|
const definedSearchEntries = Object.entries(
|
||||||
|
searchRecord || ({} as Record<string, unknown>)
|
||||||
|
)
|
||||||
|
.map(stringifySearchEntry)
|
||||||
|
.filter(isSearchEntryDefined)
|
||||||
|
|
||||||
|
const encodedSearchEntries = definedSearchEntries.map(
|
||||||
|
encodeSearchParamEntries
|
||||||
|
)
|
||||||
|
|
||||||
|
return encodedSearchEntries.length ? `?${encodedSearchEntries.join('&')}` : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stringifySearchEntry([key, value]: [string, unknown]): [
|
||||||
|
string,
|
||||||
|
undefined | string
|
||||||
|
] {
|
||||||
|
const isEmptyObjectOrArray =
|
||||||
|
typeof value === 'object' &&
|
||||||
|
value !== null &&
|
||||||
|
Object.entries(value).length === 0
|
||||||
|
if (value === undefined || value === null || isEmptyObjectOrArray) {
|
||||||
|
return [key, undefined]
|
||||||
|
}
|
||||||
|
|
||||||
|
return [key, JsonURL.stringify(value)]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseSearchFragment(
|
||||||
|
searchStringFragment: string
|
||||||
|
): null | unknown {
|
||||||
|
if (searchStringFragment === '') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const fragmentWithReEncodedSymbols = searchStringFragment
|
||||||
|
/* @ts-expect-error API supposedly not present in compilation target */
|
||||||
|
.replaceAll('=', encodeURIComponent('='))
|
||||||
|
.replaceAll('#', encodeURIComponent('#'))
|
||||||
|
try {
|
||||||
|
return JsonURL.parse(fragmentWithReEncodedSymbols)
|
||||||
|
} catch (error) {
|
||||||
|
console.error(
|
||||||
|
`Failed to parse URL fragment ${fragmentWithReEncodedSymbols}`,
|
||||||
|
error
|
||||||
|
)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseSearch(searchString: string): Record<string, unknown> {
|
||||||
|
const urlSearchParams = new URLSearchParams(searchString)
|
||||||
|
const searchRecord: Record<string, unknown> = {}
|
||||||
|
urlSearchParams.forEach((v, k) => (searchRecord[k] = parseSearchFragment(v)))
|
||||||
|
return searchRecord
|
||||||
|
}
|
@ -15,6 +15,6 @@ export default () => ({
|
|||||||
if (! this.open) return
|
if (! this.open) return
|
||||||
|
|
||||||
this.open = false
|
this.open = false
|
||||||
focusAfter && focusAfter.focus()
|
focusAfter?.focus()
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
@ -3,6 +3,7 @@ if (window.Element && !Element.prototype.closest) {
|
|||||||
function(s) {
|
function(s) {
|
||||||
var matches = (this.document || this.ownerDocument).querySelectorAll(s),
|
var matches = (this.document || this.ownerDocument).querySelectorAll(s),
|
||||||
i,
|
i,
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-this-alias
|
||||||
el = this;
|
el = this;
|
||||||
do {
|
do {
|
||||||
i = matches.length;
|
i = matches.length;
|
||||||
|
1577
assets/package-lock.json
generated
1577
assets/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -3,11 +3,12 @@
|
|||||||
"version": "1.4.0",
|
"version": "1.4.0",
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"format": "prettier --write {css,js}/**",
|
"format": "prettier --write",
|
||||||
"check-format": "prettier --check {css,js}/**",
|
"check-format": "prettier --check **/*.{js,css,ts,tsx} --require-pragma",
|
||||||
"eslint": "eslint js/**",
|
"eslint": "eslint js/**",
|
||||||
"stylelint": "stylelint css/**",
|
"stylelint": "stylelint css/**",
|
||||||
"lint": "npm run eslint && npm run stylelint"
|
"lint": "npm run eslint && npm run stylelint",
|
||||||
|
"typecheck": "tsc --noEmit --pretty"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@headlessui/react": "^1.7.10",
|
"@headlessui/react": "^1.7.10",
|
||||||
@ -24,7 +25,7 @@
|
|||||||
"chart.js": "^3.3.2",
|
"chart.js": "^3.3.2",
|
||||||
"chartjs-plugin-datalabels": "^2.2.0",
|
"chartjs-plugin-datalabels": "^2.2.0",
|
||||||
"classnames": "^2.3.1",
|
"classnames": "^2.3.1",
|
||||||
"datamaps": "^0.5.9",
|
"d3": "^7.9.0",
|
||||||
"dayjs": "^1.11.7",
|
"dayjs": "^1.11.7",
|
||||||
"iframe-resizer": "^4.3.2",
|
"iframe-resizer": "^4.3.2",
|
||||||
"phoenix": "^1.7.2",
|
"phoenix": "^1.7.2",
|
||||||
@ -38,18 +39,29 @@
|
|||||||
"react-popper": "^2.3.0",
|
"react-popper": "^2.3.0",
|
||||||
"react-router-dom": "^6.25.1",
|
"react-router-dom": "^6.25.1",
|
||||||
"react-transition-group": "^4.4.2",
|
"react-transition-group": "^4.4.2",
|
||||||
"url-search-params-polyfill": "^8.2.5"
|
"topojson-client": "^3.1.0",
|
||||||
|
"url-search-params-polyfill": "^8.2.5",
|
||||||
|
"visionscarto-world-atlas": "^1.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/classnames": "^2.3.1",
|
||||||
|
"@types/d3": "^7.4.3",
|
||||||
|
"@types/react": "^18.3.3",
|
||||||
|
"@types/react-dom": "^18.3.0",
|
||||||
|
"@types/topojson-client": "^3.1.4",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^8.0.1",
|
||||||
|
"@typescript-eslint/parser": "^8.0.1",
|
||||||
"eslint": "^8.57.0",
|
"eslint": "^8.57.0",
|
||||||
"eslint-config-prettier": "^9.1.0",
|
"eslint-config-prettier": "^9.1.0",
|
||||||
|
"eslint-import-resolver-typescript": "^3.6.1",
|
||||||
"eslint-plugin-import": "^2.29.1",
|
"eslint-plugin-import": "^2.29.1",
|
||||||
"eslint-plugin-jsx-a11y": "^6.9.0",
|
"eslint-plugin-jsx-a11y": "^6.9.0",
|
||||||
"eslint-plugin-react": "^7.35.0",
|
"eslint-plugin-react": "^7.35.0",
|
||||||
"eslint-plugin-react-hooks": "^4.6.2",
|
"eslint-plugin-react-hooks": "^4.6.2",
|
||||||
"prettier": "^3.3.3",
|
"prettier": "^3.3.3",
|
||||||
"stylelint": "^16.8.1",
|
"stylelint": "^16.8.1",
|
||||||
"stylelint-config-standard": "^36.0.1"
|
"stylelint-config-standard": "^36.0.1",
|
||||||
|
"typescript": "^5.5.4"
|
||||||
},
|
},
|
||||||
"name": "assets"
|
"name": "assets"
|
||||||
}
|
}
|
||||||
|
@ -3,7 +3,7 @@ const plugin = require('tailwindcss/plugin')
|
|||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
content: [
|
content: [
|
||||||
"./js/**/*.js",
|
"./js/**/*.{js,ts,tsx}",
|
||||||
"../lib/*_web.ex",
|
"../lib/*_web.ex",
|
||||||
"../lib/*_web/**/*.*ex",
|
"../lib/*_web/**/*.*ex",
|
||||||
"../extra/**/*.*ex",
|
"../extra/**/*.*ex",
|
||||||
|
12
assets/tsconfig.json
Normal file
12
assets/tsconfig.json
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"jsx": "react",
|
||||||
|
"target": "es2017",
|
||||||
|
"module": "commonjs",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true
|
||||||
|
}
|
||||||
|
}
|
@ -26,7 +26,7 @@ config :esbuild,
|
|||||||
]
|
]
|
||||||
|
|
||||||
config :tailwind,
|
config :tailwind,
|
||||||
version: "3.3.3",
|
version: "3.4.7",
|
||||||
default: [
|
default: [
|
||||||
args: ~w(
|
args: ~w(
|
||||||
--config=tailwind.config.js
|
--config=tailwind.config.js
|
||||||
|
@ -8,6 +8,7 @@ config :plausible, PlausibleWeb.Endpoint,
|
|||||||
watchers: [
|
watchers: [
|
||||||
esbuild: {Esbuild, :install_and_run, [:default, ~w(--sourcemap=inline --watch)]},
|
esbuild: {Esbuild, :install_and_run, [:default, ~w(--sourcemap=inline --watch)]},
|
||||||
tailwind: {Tailwind, :install_and_run, [:default, ~w(--watch)]},
|
tailwind: {Tailwind, :install_and_run, [:default, ~w(--watch)]},
|
||||||
|
npm: ["--prefix", "assets", "run", "typecheck", "--", "--watch"],
|
||||||
npm: [
|
npm: [
|
||||||
"run",
|
"run",
|
||||||
"deploy",
|
"deploy",
|
||||||
|
1
mix.exs
1
mix.exs
@ -155,6 +155,7 @@ defmodule Plausible.MixProject do
|
|||||||
"ecto.reset": ["ecto.drop", "ecto.setup"],
|
"ecto.reset": ["ecto.drop", "ecto.setup"],
|
||||||
test: ["ecto.create --quiet", "ecto.migrate", "test", "clean_clickhouse"],
|
test: ["ecto.create --quiet", "ecto.migrate", "test", "clean_clickhouse"],
|
||||||
"assets.setup": ["tailwind.install --if-missing", "esbuild.install --if-missing"],
|
"assets.setup": ["tailwind.install --if-missing", "esbuild.install --if-missing"],
|
||||||
|
"assets.typecheck": ["cmd npm --prefix assets run typecheck"],
|
||||||
"assets.build": [
|
"assets.build": [
|
||||||
"tailwind default",
|
"tailwind default",
|
||||||
"esbuild default"
|
"esbuild default"
|
||||||
|
Loading…
Reference in New Issue
Block a user