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:
Artur Pata 2024-08-13 11:39:35 +03:00 committed by GitHub
parent ee3d1e770e
commit 039f3baf8e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
42 changed files with 1975 additions and 972 deletions

View File

@ -1,3 +1,3 @@
erlang 27.0
elixir 1.17.1-otp-27
nodejs 21.0.0
nodejs 21.7.3

View File

@ -1,22 +1,20 @@
{
"parserOptions": {
"ecmaVersion": "latest",
"sourceType": "module"
},
"root": true,
"env": {
"browser": true,
"es6": true
},
"plugins": ["import"],
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"plugin:jsx-a11y/recommended",
"plugin:import/recommended",
"plugin:react/recommended",
"plugin:react-hooks/recommended",
"prettier"
],
"rules": {
"react/jsx-filename-extension": [1, { "extensions": [".js", ".jsx"] }],
"import/no-unresolved": "error",
"react/destructuring-assignment": [0],
"react/prop-types": [0],
"max-classes-per-file": [0],
@ -24,20 +22,39 @@
"react/jsx-one-expression-per-line": [0],
"react/self-closing-comp": [0],
"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],
"react/jsx-props-no-spreading": [0],
"jsx-a11y/click-events-have-key-events": [0],
"jsx-a11y/no-static-element-interactions": [0],
"react/no-did-update-set-state": [0],
"react/no-unknown-property": [2, {"ignore": ["tooltip"]}]
"react/no-unknown-property": [2, { "ignore": ["tooltip"] }]
},
"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": {
"createClass": "createReactClass", // Regex for Component Factory to use,
// default to "createReactClass"
"pragma": "React", // Pragma to use, default to "React"
"fragment": "Fragment", // Fragment to use (may be a property of <pragma>), default to "Fragment"
// default to "createReactClass"
"pragma": "React", // Pragma to use, default to "React"
"fragment": "Fragment", // Fragment to use (may be a property of <pragma>), default to "Fragment"
"version": "detect"
}
}

View File

@ -1,6 +1,6 @@
{
"singleQuote": true,
"requirePragma": true,
"insertPragma": true,
"trailingComma": "none",
"semi": false
}

View File

@ -255,11 +255,6 @@ blockquote {
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 {
@apply absolute top-0 w-screen h-full bg-gray-50 dark:bg-gray-850;

View File

@ -10,6 +10,7 @@ import * as timer from './dashboard/util/realtime-update-timer'
import { filtersBackwardsCompatibilityRedirect } from './dashboard/query';
import SiteContextProvider, { parseSiteFromDataset } from './dashboard/site-context';
import UserContextProvider from './dashboard/user-context'
import ThemeContextProvider from './dashboard/theme-context'
timer.start()
@ -32,11 +33,13 @@ if (container) {
const router = createAppRouter(site);
const app = (
<ErrorBoundary>
<SiteContextProvider site={site}>
<UserContextProvider role={container.dataset.currentUserRole} loggedIn={container.dataset.loggedIn === 'true'}>
<RouterProvider router={router} />
</UserContextProvider>
</SiteContextProvider>
<ThemeContextProvider>
<SiteContextProvider site={site}>
<UserContextProvider role={container.dataset.currentUserRole} loggedIn={container.dataset.loggedIn === 'true'}>
<RouterProvider router={router} />
</UserContextProvider>
</SiteContextProvider>
</ThemeContextProvider>
</ErrorBoundary>
)

View File

@ -10,8 +10,9 @@ export default function LazyLoader(props) {
useEffect(() => {
if (inView && !hasBecomeVisibleYet) {
setHasBecomeVisibleYet(true)
props.onVisible && props.onVisible()
if (props.onVisible) props.onVisible()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [inView])
return (

View File

@ -1,3 +1,4 @@
/* eslint-disable react-hooks/exhaustive-deps */
import React, { Fragment, useState, useEffect, useCallback, useRef } from "react";
import { useAppNavigate } from "./navigation/use-app-navigate";
import Flatpickr from "react-flatpickr";
@ -291,7 +292,7 @@ function DatePicker() {
}
function openCalendar() {
calendar.current && calendar.current.flatpickr.open();
calendar.current?.flatpickr.open();
}
function renderLink(period, text, opts = {}) {

View File

@ -46,12 +46,14 @@ export default function Funnel({ funnelName, tabs }) {
}
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [query, funnelName, visible, isSmallScreen])
useEffect(() => {
if (canvasRef.current && funnel && visible && !isSmallScreen) {
initialiseChart()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [funnel, visible])
useEffect(() => {

View File

@ -137,6 +137,7 @@ function Filters() {
window.removeEventListener('resize', handleResize, false)
document.removeEventListener("keyup", handleKeyup)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
useEffect(() => {
@ -145,6 +146,7 @@ function Filters() {
useEffect(() => {
if (wrapped === WRAPSTATE.waiting) { updateDisplayMode() }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [wrapped])

View File

@ -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'

View 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'

View File

@ -30,7 +30,7 @@ export const withPinnedHeader = (WrappedComponent, selector) => {
}
componentWillUnmount() {
this.observer && this.observer.unobserve(this.el);
this.observer?.unobserve(this.el);
}
render() {

View File

@ -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;

View 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

View File

@ -16,6 +16,7 @@ import { useUserContext } from '../../user-context'
/*global require*/
function maybeRequire() {
if (BUILD_EXTRA) {
// eslint-disable-next-line @typescript-eslint/no-require-imports
return require('../../extra/funnel')
} else {
return { default: null }
@ -65,6 +66,7 @@ export default function Behaviours({ importedDataInView }) {
setShowingPropsForGoalFilter(true)
setMode(PROPS)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
useEffect(() => {
@ -73,10 +75,12 @@ export default function Behaviours({ importedDataInView }) {
setShowingPropsForGoalFilter(false)
setMode(CONVERSIONS)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [hasGoalFilter(query)])
useEffect(() => {
setMode(defaultMode())
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [enabledModes])
useEffect(() => setLoading(true), [query, mode])

View File

@ -54,6 +54,7 @@ export default function Properties({ afterFetchData }) {
setPropKeyLoading(false)
})
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [query])
function getPropKeyFromStorage() {
@ -73,6 +74,7 @@ export default function Properties({ afterFetchData }) {
return (input) => {
return api.get(url.apiPath(site, "/suggestions/prop_key"), query, { q: input.trim() })
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [query])
function onPropKeySelect() {

View File

@ -69,6 +69,7 @@ function subscribeKeybinding(element) {
// eslint-disable-next-line react-hooks/rules-of-hooks
const handleKeyPress = useCallback((event) => {
if (isKeyPressed(event, "i")) element.current?.click()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
// eslint-disable-next-line react-hooks/rules-of-hooks

View File

@ -1,3 +1,4 @@
/* eslint-disable react-hooks/exhaustive-deps */
import React, { useState, useEffect, useRef, useCallback } from 'react';
import * as api from '../../api';
import * as storage from '../../util/storage';

View 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>
)
}

View File

@ -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} />
case "map":
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} />
}
}

View 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>
)

View File

@ -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}/>)}

View 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

View File

@ -19,6 +19,7 @@ export default function FilterModalRow({
const selectedClauses = useMemo(
() => clauses.map((value) => ({ value, label: getLabel(labels, filterKey, value) })),
// eslint-disable-next-line react-hooks/exhaustive-deps
[filter, labels]
)

View File

@ -14,7 +14,6 @@ function detailsIcon() {
strokeLinecap="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" />
</svg>
)
@ -26,7 +25,6 @@ export default function MoreLink({ linkProps, list, className, onClick }) {
<div className={`w-full text-center ${className ? className : ''}`}>
<AppNavigationLink
{...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"
onClick={onClick}
>

View File

@ -125,6 +125,7 @@ export default function ListReport({ keyLabel, metrics, colMinWidth = COL_MIN_WI
setState({ loading: false, list: response.results })
})
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [keyLabel, query])
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.
setState({ loading: true, list: null })
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [goalFilterApplied]);
useEffect(() => {
@ -145,6 +147,7 @@ export default function ListReport({ keyLabel, metrics, colMinWidth = COL_MIN_WI
}
return () => { document.removeEventListener('tick', getData) }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [keyLabel, query, visible]);
// returns a filtered `metrics` list. Since currently, the backend can return different

View File

@ -6,6 +6,7 @@ import React from "react"
/*global require*/
function maybeRequire() {
if (BUILD_EXTRA) {
// eslint-disable-next-line @typescript-eslint/no-require-imports
return require('../../extra/money')
} else {
return { default: null }

View 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)
}

View File

@ -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>
};

View 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>
)
}

View File

@ -11,7 +11,7 @@ function testLocalStorageAvailability(){
localStorage.setItem(testItem, testItem);
localStorage.removeItem(testItem);
return true;
} catch(e) {
} catch (_e) {
return false;
}
}

View File

@ -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;
}

View 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
}

View File

@ -15,6 +15,6 @@ export default () => ({
if (! this.open) return
this.open = false
focusAfter && focusAfter.focus()
focusAfter?.focus()
},
})

View File

@ -3,6 +3,7 @@ if (window.Element && !Element.prototype.closest) {
function(s) {
var matches = (this.document || this.ownerDocument).querySelectorAll(s),
i,
// eslint-disable-next-line @typescript-eslint/no-this-alias
el = this;
do {
i = matches.length;

1577
assets/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -3,11 +3,12 @@
"version": "1.4.0",
"license": "AGPL-3.0-or-later",
"scripts": {
"format": "prettier --write {css,js}/**",
"check-format": "prettier --check {css,js}/**",
"format": "prettier --write",
"check-format": "prettier --check **/*.{js,css,ts,tsx} --require-pragma",
"eslint": "eslint js/**",
"stylelint": "stylelint css/**",
"lint": "npm run eslint && npm run stylelint"
"lint": "npm run eslint && npm run stylelint",
"typecheck": "tsc --noEmit --pretty"
},
"dependencies": {
"@headlessui/react": "^1.7.10",
@ -24,7 +25,7 @@
"chart.js": "^3.3.2",
"chartjs-plugin-datalabels": "^2.2.0",
"classnames": "^2.3.1",
"datamaps": "^0.5.9",
"d3": "^7.9.0",
"dayjs": "^1.11.7",
"iframe-resizer": "^4.3.2",
"phoenix": "^1.7.2",
@ -38,18 +39,29 @@
"react-popper": "^2.3.0",
"react-router-dom": "^6.25.1",
"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": {
"@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-config-prettier": "^9.1.0",
"eslint-import-resolver-typescript": "^3.6.1",
"eslint-plugin-import": "^2.29.1",
"eslint-plugin-jsx-a11y": "^6.9.0",
"eslint-plugin-react": "^7.35.0",
"eslint-plugin-react-hooks": "^4.6.2",
"prettier": "^3.3.3",
"stylelint": "^16.8.1",
"stylelint-config-standard": "^36.0.1"
"stylelint-config-standard": "^36.0.1",
"typescript": "^5.5.4"
},
"name": "assets"
}

View File

@ -3,7 +3,7 @@ const plugin = require('tailwindcss/plugin')
module.exports = {
content: [
"./js/**/*.js",
"./js/**/*.{js,ts,tsx}",
"../lib/*_web.ex",
"../lib/*_web/**/*.*ex",
"../extra/**/*.*ex",

12
assets/tsconfig.json Normal file
View File

@ -0,0 +1,12 @@
{
"compilerOptions": {
"jsx": "react",
"target": "es2017",
"module": "commonjs",
"resolveJsonModule": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"skipLibCheck": true
}
}

View File

@ -26,7 +26,7 @@ config :esbuild,
]
config :tailwind,
version: "3.3.3",
version: "3.4.7",
default: [
args: ~w(
--config=tailwind.config.js

View File

@ -8,6 +8,7 @@ config :plausible, PlausibleWeb.Endpoint,
watchers: [
esbuild: {Esbuild, :install_and_run, [:default, ~w(--sourcemap=inline --watch)]},
tailwind: {Tailwind, :install_and_run, [:default, ~w(--watch)]},
npm: ["--prefix", "assets", "run", "typecheck", "--", "--watch"],
npm: [
"run",
"deploy",

View File

@ -155,6 +155,7 @@ defmodule Plausible.MixProject do
"ecto.reset": ["ecto.drop", "ecto.setup"],
test: ["ecto.create --quiet", "ecto.migrate", "test", "clean_clickhouse"],
"assets.setup": ["tailwind.install --if-missing", "esbuild.install --if-missing"],
"assets.typecheck": ["cmd npm --prefix assets run typecheck"],
"assets.build": [
"tailwind default",
"esbuild default"