Moved code into src folder. Added build for component library.

This commit is contained in:
Mike Cao 2023-08-21 02:06:09 -07:00
parent 7a7233ead4
commit ede658771e
490 changed files with 749 additions and 442 deletions

View file

@ -0,0 +1,23 @@
export * from './useApi';
export * from './useConfig';
export * from './useCountryNames';
export * from './useDateRange';
export * from './useDocumentClick';
export * from './useEscapeKey';
export * from './useFilters';
export * from './useForceUpdate';
export * from './useFormat';
export * from './useLanguageNames';
export * from './useLocale';
export * from './useMessages';
export * from './usePageQuery';
export * from './useReport';
export * from './useReports';
export * from './useRequireLogin';
export * from './useShareToken';
export * from './useSticky';
export * from './useTheme';
export * from './useTimezone';
export * from './useUser';
export * from './useWebsite';
export * from './useWebsiteReports';

View file

@ -0,0 +1,22 @@
import { useRouter } from 'next/router';
import * as reactQuery from '@tanstack/react-query';
import { useApi as nextUseApi } from 'next-basics';
import { getClientAuthToken } from 'lib/client';
import { SHARE_TOKEN_HEADER } from 'lib/constants';
import useStore from 'store/app';
const selector = state => state.shareToken;
export function useApi() {
const { basePath } = useRouter();
const shareToken = useStore(selector);
const { get, post, put, del } = nextUseApi(
{ authorization: `Bearer ${getClientAuthToken()}`, [SHARE_TOKEN_HEADER]: shareToken?.token },
basePath,
);
return { get, post, put, del, ...reactQuery };
}
export default useApi;

View file

@ -0,0 +1,28 @@
import { useState } from 'react';
export function useApiFilter() {
const [filter, setFilter] = useState();
const [filterType, setFilterType] = useState('All');
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const handleFilterChange = value => setFilter(value);
const handlePageChange = value => setPage(value);
const handlePageSizeChange = value => setPageSize(value);
return {
filter,
setFilter,
filterType,
setFilterType,
page,
setPage,
pageSize,
setPageSize,
handleFilterChange,
handlePageChange,
handlePageSizeChange,
};
}
export default useApiFilter;

View file

@ -0,0 +1,27 @@
import { useEffect } from 'react';
import useStore, { setConfig } from 'store/app';
import useApi from 'components/hooks/useApi';
let loading = false;
export function useConfig() {
const { config } = useStore();
const { get } = useApi();
async function loadConfig() {
const data = await get('/config');
loading = false;
setConfig(data);
}
useEffect(() => {
if (!config && !loading) {
loading = true;
loadConfig();
}
}, []);
return config;
}
export default useConfig;

View file

@ -0,0 +1,36 @@
import { useState, useEffect } from 'react';
import { useRouter } from 'next/router';
import { httpGet } from 'next-basics';
import enUS from 'public/intl/country/en-US.json';
const countryNames = {
'en-US': enUS,
};
export function useCountryNames(locale) {
const [list, setList] = useState(countryNames[locale] || enUS);
const { basePath } = useRouter();
async function loadData(locale) {
const { data } = await httpGet(`${basePath}/intl/country/${locale}.json`);
if (data) {
countryNames[locale] = data;
setList(countryNames[locale]);
} else {
setList(enUS);
}
}
useEffect(() => {
if (!countryNames[locale]) {
loadData(locale);
} else {
setList(countryNames[locale]);
}
}, [locale]);
return list;
}
export default useCountryNames;

View file

@ -0,0 +1,50 @@
import { getMinimumUnit, parseDateRange } from 'lib/date';
import { setItem } from 'next-basics';
import { DATE_RANGE_CONFIG, DEFAULT_DATE_RANGE } from 'lib/constants';
import useLocale from './useLocale';
import websiteStore, { setWebsiteDateRange } from 'store/websites';
import appStore, { setDateRange } from 'store/app';
import useApi from './useApi';
export function useDateRange(websiteId) {
const { get } = useApi();
const { locale } = useLocale();
const websiteConfig = websiteStore(state => state[websiteId]?.dateRange);
const defaultConfig = DEFAULT_DATE_RANGE;
const globalConfig = appStore(state => state.dateRange);
const dateRange = parseDateRange(websiteConfig || globalConfig || defaultConfig, locale);
const saveDateRange = async value => {
if (websiteId) {
let dateRange = value;
if (typeof value === 'string') {
if (value === 'all') {
const result = await get(`/websites/${websiteId}/daterange`);
const { mindate, maxdate } = result;
const startDate = new Date(mindate);
const endDate = new Date(maxdate);
dateRange = {
startDate,
endDate,
unit: getMinimumUnit(startDate, endDate),
value,
};
} else {
dateRange = parseDateRange(value, locale);
}
}
setWebsiteDateRange(websiteId, dateRange);
} else {
setItem(DATE_RANGE_CONFIG, value);
setDateRange(value);
}
};
return [dateRange, saveDateRange];
}
export default useDateRange;

View file

@ -0,0 +1,15 @@
import { useEffect } from 'react';
export function useDocumentClick(handler) {
useEffect(() => {
document.addEventListener('click', handler);
return () => {
document.removeEventListener('click', handler);
};
}, [handler]);
return null;
}
export default useDocumentClick;

View file

@ -0,0 +1,21 @@
import { useEffect, useCallback } from 'react';
export function useEscapeKey(handler) {
const escFunction = useCallback(event => {
if (event.keyCode === 27) {
handler(event);
}
}, []);
useEffect(() => {
document.addEventListener('keydown', escFunction, false);
return () => {
document.removeEventListener('keydown', escFunction, false);
};
}, [escFunction]);
return null;
}
export default useEscapeKey;

View file

@ -0,0 +1,47 @@
import { useMessages } from './useMessages';
import { OPERATORS } from 'lib/constants';
export function useFilters() {
const { formatMessage, labels } = useMessages();
const filterLabels = {
[OPERATORS.equals]: formatMessage(labels.is),
[OPERATORS.notEquals]: formatMessage(labels.isNot),
[OPERATORS.set]: formatMessage(labels.isSet),
[OPERATORS.notSet]: formatMessage(labels.isNotSet),
[OPERATORS.contains]: formatMessage(labels.contains),
[OPERATORS.doesNotContain]: formatMessage(labels.doesNotContain),
[OPERATORS.true]: formatMessage(labels.true),
[OPERATORS.false]: formatMessage(labels.false),
[OPERATORS.greaterThan]: formatMessage(labels.greaterThan),
[OPERATORS.lessThan]: formatMessage(labels.lessThan),
[OPERATORS.greaterThanEquals]: formatMessage(labels.greaterThanEquals),
[OPERATORS.lessThanEquals]: formatMessage(labels.lessThanEquals),
[OPERATORS.before]: formatMessage(labels.before),
[OPERATORS.after]: formatMessage(labels.after),
};
const typeFilters = {
string: [OPERATORS.equals, OPERATORS.notEquals],
array: [OPERATORS.contains, OPERATORS.doesNotContain],
boolean: [OPERATORS.true, OPERATORS.false],
number: [
OPERATORS.equals,
OPERATORS.notEquals,
OPERATORS.greaterThan,
OPERATORS.lessThan,
OPERATORS.greaterThanEquals,
OPERATORS.lessThanEquals,
],
date: [OPERATORS.before, OPERATORS.after],
uuid: [OPERATORS.equals],
};
const getFilters = type => {
return typeFilters[type]?.map(key => ({ type, value: key, label: filterLabels[key] })) ?? [];
};
return { getFilters, filterLabels, typeFilters };
}
export default useFilters;

View file

@ -0,0 +1,11 @@
import { useCallback, useState } from 'react';
export function useForceUpdate() {
const [, update] = useState(Object.create(null));
return useCallback(() => {
update(Object.create(null));
}, [update]);
}
export default useForceUpdate;

View file

@ -0,0 +1,39 @@
import useMessages from './useMessages';
import { BROWSERS } from 'lib/constants';
import useLocale from './useLocale';
import useCountryNames from './useCountryNames';
export function useFormat() {
const { formatMessage, labels } = useMessages();
const { locale } = useLocale();
const countryNames = useCountryNames(locale);
const formatBrowser = value => {
return BROWSERS[value] || value;
};
const formatCountry = value => {
return countryNames[value] || value;
};
const formatDevice = value => {
return formatMessage(labels[value] || labels.unknown);
};
const formatValue = (value, type) => {
switch (type) {
case 'browser':
return formatBrowser(value);
case 'country':
return formatCountry(value);
case 'device':
return formatDevice(value);
default:
return value;
}
};
return { formatBrowser, formatCountry, formatDevice, formatValue };
}
export default useFormat;

View file

@ -0,0 +1,36 @@
import { useState, useEffect } from 'react';
import { useRouter } from 'next/router';
import { httpGet } from 'next-basics';
import enUS from 'public/intl/language/en-US.json';
const languageNames = {
'en-US': enUS,
};
export function useLanguageNames(locale) {
const [list, setList] = useState(languageNames[locale] || enUS);
const { basePath } = useRouter();
async function loadData(locale) {
const { data } = await httpGet(`${basePath}/intl/language/${locale}.json`);
if (data) {
languageNames[locale] = data;
setList(languageNames[locale]);
} else {
setList(enUS);
}
}
useEffect(() => {
if (!languageNames[locale]) {
loadData(locale);
} else {
setList(languageNames[locale]);
}
}, [locale]);
return list;
}
export default useLanguageNames;

View file

@ -0,0 +1,65 @@
import { useEffect } from 'react';
import { useRouter } from 'next/router';
import { httpGet, setItem } from 'next-basics';
import { LOCALE_CONFIG } from 'lib/constants';
import { getDateLocale, getTextDirection } from 'lib/lang';
import useStore, { setLocale } from 'store/app';
import useForceUpdate from 'components/hooks/useForceUpdate';
import enUS from 'public/intl/messages/en-US.json';
const messages = {
'en-US': enUS,
};
const selector = state => state.locale;
export function useLocale() {
const locale = useStore(selector);
const { basePath } = useRouter();
const forceUpdate = useForceUpdate();
const dir = getTextDirection(locale);
const dateLocale = getDateLocale(locale);
async function loadMessages(locale) {
const { ok, data } = await httpGet(`${basePath}/intl/messages/${locale}.json`);
if (ok) {
messages[locale] = data;
}
}
async function saveLocale(value) {
if (!messages[value]) {
await loadMessages(value);
}
setItem(LOCALE_CONFIG, value);
document.getElementById('__next')?.setAttribute('dir', getTextDirection(value));
if (locale !== value) {
setLocale(value);
} else {
forceUpdate();
}
}
useEffect(() => {
if (!messages[locale]) {
saveLocale(locale);
}
}, [locale]);
useEffect(() => {
const url = new URL(window?.location?.href);
const locale = url.searchParams.get('locale');
if (locale) {
saveLocale(locale);
}
}, []);
return { locale, saveLocale, messages, dir, dateLocale };
}
export default useLocale;

View file

@ -0,0 +1,20 @@
import { useIntl, FormattedMessage } from 'react-intl';
import { messages, labels } from 'components/messages';
export function useMessages() {
const intl = useIntl();
const getMessage = id => {
const message = Object.values(messages).find(value => value.id === id);
return message ? formatMessage(message) : id;
};
const formatMessage = (descriptor, values, opts) => {
return descriptor ? intl.formatMessage(descriptor, values, opts) : null;
};
return { formatMessage, FormattedMessage, messages, labels, getMessage };
}
export default useMessages;

View file

@ -0,0 +1,33 @@
import { useMemo } from 'react';
import { useRouter } from 'next/router';
import { buildUrl } from 'next-basics';
export function usePageQuery() {
const router = useRouter();
const { pathname, search } = location;
const { asPath } = router;
const query = useMemo(() => {
if (!search) {
return {};
}
const params = search.substring(1).split('&');
return params.reduce((obj, item) => {
const [key, value] = item.split('=');
obj[key] = decodeURIComponent(value);
return obj;
}, {});
}, [search]);
function resolveUrl(params, reset) {
return buildUrl(asPath.split('?')[0], { ...(reset ? {} : query), ...params });
}
return { pathname, query, resolveUrl, router };
}
export default usePageQuery;

View file

@ -0,0 +1,86 @@
import { produce } from 'immer';
import { useCallback, useEffect, useState } from 'react';
import { useTimezone } from './useTimezone';
import useApi from './useApi';
const baseParameters = {
name: 'Untitled',
description: '',
parameters: {},
};
export function useReport(reportId, defaultParameters) {
const [report, setReport] = useState(null);
const [isRunning, setIsRunning] = useState(false);
const { get, post } = useApi();
const [timezone] = useTimezone();
const loadReport = async id => {
const data = await get(`/reports/${id}`);
const { dateRange } = data?.parameters || {};
const { startDate, endDate } = dateRange || {};
if (startDate && endDate) {
dateRange.startDate = new Date(startDate);
dateRange.endDate = new Date(endDate);
}
setReport(data);
};
const runReport = useCallback(
async parameters => {
setIsRunning(true);
const { type } = report;
const data = await post(`/reports/${type}`, { ...parameters, timezone });
setReport(
produce(state => {
state.parameters = parameters;
state.data = data;
return state;
}),
);
setIsRunning(false);
},
[report],
);
const updateReport = useCallback(
async data => {
setReport(
produce(state => {
const { parameters, ...rest } = data;
if (parameters) {
state.parameters = { ...state.parameters, ...parameters };
}
for (const key in rest) {
state[key] = rest[key];
}
return state;
}),
);
},
[report],
);
useEffect(() => {
if (!reportId) {
setReport({ ...baseParameters, ...defaultParameters });
} else {
loadReport(reportId);
}
}, []);
return { report, runReport, updateReport, isRunning };
}
export default useReport;

View file

@ -0,0 +1,38 @@
import { useState } from 'react';
import useApi from './useApi';
import useApiFilter from 'components/hooks/useApiFilter';
export function useReports() {
const [modified, setModified] = useState(Date.now());
const { get, useQuery, del, useMutation } = useApi();
const { mutate } = useMutation(reportId => del(`/reports/${reportId}`));
const { filter, page, pageSize, handleFilterChange, handlePageChange, handlePageSizeChange } =
useApiFilter();
const { data, error, isLoading } = useQuery(
['reports', { modified, filter, page, pageSize }],
() => get(`/reports`, { filter, page, pageSize }),
);
const deleteReport = id => {
mutate(id, {
onSuccess: () => {
setModified(Date.now());
},
});
};
return {
reports: data,
error,
isLoading,
deleteReport,
filter,
page,
pageSize,
handleFilterChange,
handlePageChange,
handlePageSizeChange,
};
}
export default useReports;

View file

@ -0,0 +1,30 @@
import { useEffect } from 'react';
import { useRouter } from 'next/router';
import useApi from 'components/hooks/useApi';
import useUser from 'components/hooks/useUser';
export function useRequireLogin() {
const router = useRouter();
const { get } = useApi();
const { user, setUser } = useUser();
useEffect(() => {
async function loadUser() {
try {
const { user } = await get('/auth/verify');
setUser(user);
} catch {
await router.push('/login');
}
}
if (!user) {
loadUser();
}
}, [user]);
return { user };
}
export default useRequireLogin;

View file

@ -0,0 +1,28 @@
import { useEffect } from 'react';
import useStore, { setShareToken } from 'store/app';
import useApi from './useApi';
const selector = state => state.shareToken;
export function useShareToken(shareId) {
const shareToken = useStore(selector);
const { get } = useApi();
async function loadToken(id) {
const data = await get(`/share/${id}`);
if (data) {
setShareToken(data);
}
}
useEffect(() => {
if (shareId) {
loadToken(shareId);
}
}, [shareId]);
return shareToken;
}
export default useShareToken;

View file

@ -0,0 +1,25 @@
import { useState, useEffect, useRef } from 'react';
export function useSticky({ enabled = true, threshold = 1 }) {
const [isSticky, setIsSticky] = useState(false);
const ref = useRef(null);
useEffect(() => {
let observer;
const handler = ([entry]) => setIsSticky(entry.intersectionRatio < threshold);
if (enabled && ref.current) {
observer = new IntersectionObserver(handler, { threshold: [threshold] });
observer.observe(ref.current);
}
return () => {
if (observer) {
observer.disconnect();
}
};
}, [ref, enabled, threshold]);
return { ref, isSticky };
}
export default useSticky;

View file

@ -0,0 +1,68 @@
import { useEffect } from 'react';
import useStore, { setTheme } from 'store/app';
import { getItem, setItem } from 'next-basics';
import { THEME_COLORS, THEME_CONFIG } from 'lib/constants';
import { colord } from 'colord';
const selector = state => state.theme;
export function useTheme() {
const defaultTheme =
typeof window !== 'undefined'
? window?.matchMedia('(prefers-color-scheme: dark)')?.matches
? 'dark'
: 'light'
: 'light';
const theme = useStore(selector) || getItem(THEME_CONFIG) || defaultTheme;
const primaryColor = colord(THEME_COLORS[theme].primary);
const colors = {
theme: {
...THEME_COLORS[theme],
},
chart: {
text: THEME_COLORS[theme].gray700,
line: THEME_COLORS[theme].gray200,
views: {
hoverBackgroundColor: primaryColor.alpha(0.7).toRgbString(),
backgroundColor: primaryColor.alpha(0.4).toRgbString(),
borderColor: primaryColor.alpha(0.7).toRgbString(),
hoverBorderColor: primaryColor.toRgbString(),
},
visitors: {
hoverBackgroundColor: primaryColor.alpha(0.9).toRgbString(),
backgroundColor: primaryColor.alpha(0.6).toRgbString(),
borderColor: primaryColor.alpha(0.9).toRgbString(),
hoverBorderColor: primaryColor.toRgbString(),
},
},
map: {
baseColor: THEME_COLORS[theme].primary,
fillColor: THEME_COLORS[theme].gray100,
strokeColor: THEME_COLORS[theme].primary,
hoverColor: THEME_COLORS[theme].primary,
},
};
function saveTheme(value) {
setItem(THEME_CONFIG, value);
setTheme(value);
}
useEffect(() => {
document.body.setAttribute('data-theme', theme);
}, [theme]);
useEffect(() => {
const url = new URL(window?.location?.href);
const theme = url.searchParams.get('theme');
if (['light', 'dark'].includes(theme)) {
saveTheme(theme);
}
}, []);
return { theme, saveTheme, colors };
}
export default useTheme;

View file

@ -0,0 +1,20 @@
import { useState, useCallback } from 'react';
import { getTimezone } from 'lib/date';
import { getItem, setItem } from 'next-basics';
import { TIMEZONE_CONFIG } from 'lib/constants';
export function useTimezone() {
const [timezone, setTimezone] = useState(getItem(TIMEZONE_CONFIG) || getTimezone());
const saveTimezone = useCallback(
value => {
setItem(TIMEZONE_CONFIG, value);
setTimezone(value);
},
[setTimezone],
);
return [timezone, saveTimezone];
}
export default useTimezone;

View file

@ -0,0 +1,11 @@
import useStore, { setUser } from 'store/app';
const selector = state => state.user;
export function useUser() {
const user = useStore(selector);
return { user, setUser };
}
export default useUser;

View file

@ -0,0 +1,10 @@
import useApi from './useApi';
export function useWebsite(websiteId) {
const { get, useQuery } = useApi();
return useQuery(['websites', websiteId], () => get(`/websites/${websiteId}`), {
enabled: !!websiteId,
});
}
export default useWebsite;

View file

@ -0,0 +1,38 @@
import { useState } from 'react';
import useApi from './useApi';
import useApiFilter from 'components/hooks/useApiFilter';
export function useWebsiteReports(websiteId) {
const [modified, setModified] = useState(Date.now());
const { get, useQuery, del, useMutation } = useApi();
const { mutate } = useMutation(reportId => del(`/reports/${reportId}`));
const { filter, page, pageSize, handleFilterChange, handlePageChange, handlePageSizeChange } =
useApiFilter();
const { data, error, isLoading } = useQuery(
['reports:website', { websiteId, modified, filter, page, pageSize }],
() => get(`/websites/${websiteId}/reports`, { websiteId, filter, page, pageSize }),
);
const deleteReport = id => {
mutate(id, {
onSuccess: () => {
setModified(Date.now());
},
});
};
return {
reports: data,
error,
isLoading,
deleteReport,
filter,
page,
pageSize,
handleFilterChange,
handlePageChange,
handlePageSizeChange,
};
}
export default useWebsiteReports;