Merge branch 'dev' into os-names

This commit is contained in:
Mike Cao 2024-02-14 22:15:08 -08:00 committed by GitHub
commit 37e28bbf74
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
472 changed files with 5460 additions and 5026 deletions

View file

@ -0,0 +1,71 @@
import md5 from 'md5';
import { colord, extend } from 'colord';
import harmoniesPlugin from 'colord/plugins/harmonies';
import mixPlugin from 'colord/plugins/mix';
extend([harmoniesPlugin, mixPlugin]);
const harmonies = [
//'analogous',
//'complementary',
'double-split-complementary',
//'rectangle',
'split-complementary',
'tetradic',
//'triadic',
];
const color = (value: string, invert: boolean = false) => {
const c = colord(value.startsWith('#') ? value : `#${value}`);
if (invert && c.isDark()) {
return c.invert();
}
return c;
};
const remix = (hash: string) => {
const a = hash.substring(0, 6);
const b = hash.substring(6, 12);
const c = hash.substring(12, 18);
const d = hash.substring(18, 24);
const e = hash.substring(24, 30);
const f = hash.substring(30, 32);
const base = [b, c, d, e]
.reduce((acc, val) => {
return acc.mix(color(val), 0.05);
}, color(a))
.saturate(0.1)
.toHex();
const harmony = pick(parseInt(f, 16), harmonies);
return color(base, true)
.harmonies(harmony)
.map(c => c.toHex());
};
const pick = (num: number, arr: any[]) => {
return arr[num % arr.length];
};
export function Avatar({ value }: { value: string }) {
const hash = md5(value);
const colors = remix(hash);
return (
<svg viewBox="0 0 100 100">
<defs>
<linearGradient id={`color-${hash}`} gradientTransform="rotate(90)">
<stop offset="0%" stopColor={colors[1]} />
<stop offset="100%" stopColor={colors[2]} />
</linearGradient>
</defs>
<circle cx="50" cy="50" r="50" fill={`url(#color-${hash})`} />
</svg>
);
}
export default Avatar;

View file

@ -1,35 +0,0 @@
import { useState } from 'react';
import { Button, LoadingButton, Form, FormButtons } from 'react-basics';
import useMessages from 'components/hooks/useMessages';
export interface ConfirmDeleteFormProps {
name: string;
onConfirm?: () => void;
onClose?: () => void;
}
export function ConfirmDeleteForm({ name, onConfirm, onClose }: ConfirmDeleteFormProps) {
const [loading, setLoading] = useState(false);
const { formatMessage, labels, messages, FormattedMessage } = useMessages();
const handleConfirm = () => {
setLoading(true);
onConfirm();
};
return (
<Form>
<p>
<FormattedMessage {...messages.confirmDelete} values={{ target: <b>{name}</b> }} />
</p>
<FormButtons flex>
<LoadingButton isLoading={loading} onClick={handleConfirm} variant="danger">
{formatMessage(labels.delete)}
</LoadingButton>
<Button onClick={onClose}>{formatMessage(labels.cancel)}</Button>
</FormButtons>
</Form>
);
}
export default ConfirmDeleteForm;

View file

@ -0,0 +1,39 @@
import { ReactNode } from 'react';
import { Button, LoadingButton, Form, FormButtons } from 'react-basics';
import { useMessages } from 'components/hooks';
export interface ConfirmationFormProps {
message: ReactNode;
buttonLabel?: ReactNode;
buttonVariant?: 'none' | 'primary' | 'secondary' | 'quiet' | 'danger';
isLoading?: boolean;
error?: string | Error;
onConfirm?: () => void;
onClose?: () => void;
}
export function ConfirmationForm({
message,
buttonLabel,
buttonVariant,
isLoading,
error,
onConfirm,
onClose,
}: ConfirmationFormProps) {
const { formatMessage, labels } = useMessages();
return (
<Form error={error}>
<p>{message}</p>
<FormButtons flex>
<LoadingButton isLoading={isLoading} onClick={onConfirm} variant={buttonVariant}>
{buttonLabel || formatMessage(labels.ok)}
</LoadingButton>
<Button onClick={onClose}>{formatMessage(labels.cancel)}</Button>
</FormButtons>
</Form>
);
}
export default ConfirmationForm;

View file

@ -29,10 +29,6 @@
gap: 10px;
min-height: 70px;
align-items: center;
min-width: min-content;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.body > div > div > div {

View file

@ -5,7 +5,7 @@ import { useMessages } from 'components/hooks';
import Empty from 'components/common/Empty';
import Pager from 'components/common/Pager';
import styles from './DataTable.module.css';
import { FilterQueryResult } from 'components/hooks/useFilterQuery';
import { FilterQueryResult } from 'lib/types';
const DEFAULT_SEARCH_DELAY = 600;
@ -64,7 +64,7 @@ export function DataTable({
className={classNames(styles.body, { [styles.status]: isLoading || noResults || !hasData })}
>
{hasData ? (typeof children === 'function' ? children(result) : children) : null}
{isLoading && <Loading icon="dots" />}
{isLoading && <Loading position="page" />}
{!isLoading && !hasData && !query && <Empty />}
{noResults && <Empty message={formatMessage(messages.noResultsFound)} />}
</div>

View file

@ -8,4 +8,5 @@
text-align: center;
width: 100%;
height: 100%;
min-height: 70px;
}

View file

@ -1,5 +1,5 @@
import classNames from 'classnames';
import useMessages from 'components/hooks/useMessages';
import { useMessages } from 'components/hooks';
import styles from './Empty.module.css';
export interface EmptyProps {

View file

@ -1,7 +1,7 @@
import { ErrorInfo, ReactNode } from 'react';
import { ErrorBoundary as Boundary } from 'react-error-boundary';
import { Button } from 'react-basics';
import useMessages from 'components/hooks/useMessages';
import { useMessages } from 'components/hooks';
import styles from './ErrorBoundary.module.css';
const logError = (error: Error, info: ErrorInfo) => {

View file

@ -1,6 +1,6 @@
import { Icon, Icons, Text } from 'react-basics';
import styles from './ErrorMessage.module.css';
import useMessages from 'components/hooks/useMessages';
import { useMessages } from 'components/hooks';
export function ErrorMessage() {
const { formatMessage, messages } = useMessages();

View file

@ -3,8 +3,8 @@ import { Icon, Icons } from 'react-basics';
import classNames from 'classnames';
import Link from 'next/link';
import { safeDecodeURI } from 'next-basics';
import useNavigation from 'components/hooks/useNavigation';
import useMessages from 'components/hooks/useMessages';
import { useNavigation } from 'components/hooks';
import { useMessages } from 'components/hooks';
import styles from './FilterLink.module.css';
export interface FilterLinkProps {
@ -25,7 +25,7 @@ export function FilterLink({
className,
}: FilterLinkProps) {
const { formatMessage, labels } = useMessages();
const { makeUrl, query } = useNavigation();
const { renderUrl, query } = useNavigation();
const active = query[id] !== undefined;
const selected = query[id] === value;
@ -39,7 +39,7 @@ export function FilterLink({
{children}
{!value && `(${label || formatMessage(labels.unknown)})`}
{value && (
<Link href={makeUrl({ [id]: value })} className={styles.label} replace>
<Link href={renderUrl({ [id]: value })} className={styles.label} replace>
{safeDecodeURI(label || value)}
</Link>
)}

View file

@ -1,6 +1,6 @@
import classNames from 'classnames';
import { Button, Icon, Icons } from 'react-basics';
import useMessages from 'components/hooks/useMessages';
import { useMessages } from 'components/hooks';
import styles from './Pager.module.css';
export interface PagerProps {

View file

@ -0,0 +1,58 @@
import {
Button,
Form,
FormButtons,
FormRow,
FormInput,
TextField,
SubmitButton,
} from 'react-basics';
import { useMessages } from 'components/hooks';
export function TypeConfirmationForm({
confirmationValue,
buttonLabel,
buttonVariant,
isLoading,
error,
onConfirm,
onClose,
}: {
confirmationValue: string;
buttonLabel?: string;
buttonVariant?: 'none' | 'primary' | 'secondary' | 'quiet' | 'danger';
isLoading?: boolean;
error?: string | Error;
onConfirm?: () => void;
onClose?: () => void;
}) {
const { formatMessage, labels, messages, FormattedMessage } = useMessages();
if (!confirmationValue) {
return null;
}
return (
<Form onSubmit={onConfirm} error={error}>
<p>
<FormattedMessage
{...messages.actionConfirmation}
values={{ confirmation: <b>{confirmationValue}</b> }}
/>
</p>
<FormRow label={formatMessage(labels.confirm)}>
<FormInput name="confirm" rules={{ validate: value => value === confirmationValue }}>
<TextField autoComplete="off" />
</FormInput>
</FormRow>
<FormButtons flex>
<SubmitButton isLoading={isLoading} variant={buttonVariant}>
{buttonLabel || formatMessage(labels.ok)}
</SubmitButton>
<Button onClick={onClose}>{formatMessage(labels.cancel)}</Button>
</FormButtons>
</Form>
);
}
export default TypeConfirmationForm;

View file

@ -1,22 +0,0 @@
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 './useNavigation';
export * from './useReport';
export * from './useReports';
export * from './useLogin';
export * from './useShareToken';
export * from './useSticky';
export * from './useTheme';
export * from './useTimezone';
export * from './useUser';
export * from './useWebsite';

View file

@ -0,0 +1,33 @@
export * from './queries/useApi';
export * from './queries/useConfig';
export * from './queries/useFilterQuery';
export * from './queries/useLogin';
export * from './queries/useReport';
export * from './queries/useReports';
export * from './queries/useShareToken';
export * from './queries/useTeam';
export * from './queries/useTeams';
export * from './queries/useTeamWebsites';
export * from './queries/useTeamMembers';
export * from './queries/useUser';
export * from './queries/useUsers';
export * from './queries/useWebsite';
export * from './queries/useWebsites';
export * from './queries/useWebsiteEvents';
export * from './queries/useWebsiteMetrics';
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 './useModified';
export * from './useNavigation';
export * from './useSticky';
export * from './useTeamUrl';
export * from './useTheme';
export * from './useTimezone';

View file

@ -4,7 +4,7 @@ import { getClientAuthToken } from 'lib/client';
import { SHARE_TOKEN_HEADER } from 'lib/constants';
import useStore from 'store/app';
const selector = state => state.shareToken;
const selector = (state: { shareToken: { token?: string } }) => state.shareToken;
export function useApi() {
const shareToken = useStore(selector);

View file

@ -1,6 +1,6 @@
import { useEffect } from 'react';
import useStore, { setConfig } from 'store/app';
import useApi from 'components/hooks/useApi';
import { useApi } from './useApi';
let loading = false;

View file

@ -1,16 +1,9 @@
import { UseQueryOptions } from '@tanstack/react-query';
import { useState, Dispatch, SetStateAction } from 'react';
import { useApi } from 'components/hooks/useApi';
import { FilterResult, SearchFilter } from 'lib/types';
import { useState } from 'react';
import { useApi } from './useApi';
import { FilterResult, SearchFilter, FilterQueryResult } from 'lib/types';
export interface FilterQueryResult<T> {
result: FilterResult<T>;
query: any;
params: SearchFilter;
setParams: Dispatch<SetStateAction<T | SearchFilter>>;
}
export function useFilterQuery<T>({
export function useFilterQuery<T = any>({
queryKey,
queryFn,
...options

View file

@ -0,0 +1,29 @@
import useStore, { setUser } from 'store/app';
import useApi from './useApi';
import { UseQueryResult } from '@tanstack/react-query';
const selector = (state: { user: any }) => state.user;
export function useLogin(): {
user: any;
setUser: (data: any) => void;
} & UseQueryResult {
const { get, useQuery } = useApi();
const user = useStore(selector);
const query = useQuery({
queryKey: ['login'],
queryFn: async () => {
const data = await get('/auth/verify');
setUser(data);
return data;
},
enabled: !user,
});
return { user, setUser, ...query };
}
export default useLogin;

View file

@ -1,8 +1,8 @@
import { produce } from 'immer';
import { useCallback, useEffect, useState } from 'react';
import { useTimezone } from './useTimezone';
import useApi from './useApi';
import useMessages from './useMessages';
import { useApi } from './useApi';
import { useTimezone } from '../useTimezone';
import { useMessages } from '../useMessages';
export function useReport(reportId: string, defaultParameters: { [key: string]: any }) {
const [report, setReport] = useState(null);

View file

@ -0,0 +1,28 @@
import useApi from './useApi';
import useFilterQuery from './useFilterQuery';
import useModified from '../useModified';
export function useReports({ websiteId, teamId }: { websiteId?: string; teamId?: string }) {
const { modified } = useModified(`reports`);
const { get, del, useMutation } = useApi();
const queryResult = useFilterQuery({
queryKey: ['reports', { websiteId, teamId, modified }],
queryFn: (params: any) => {
return get('/reports', { websiteId, teamId, ...params });
},
});
const { mutate } = useMutation({ mutationFn: (reportId: string) => del(`/reports/${reportId}`) });
const deleteReport = (reportId: any) => {
mutate(reportId, {
onSuccess: () => {},
});
};
return {
...queryResult,
deleteReport,
};
}
export default useReports;

View file

@ -0,0 +1,12 @@
import useApi from './useApi';
export function useTeam(teamId: string) {
const { get, useQuery } = useApi();
return useQuery({
queryKey: ['teams', teamId],
queryFn: () => get(`/teams/${teamId}`),
enabled: !!teamId,
});
}
export default useTeam;

View file

@ -0,0 +1,18 @@
import useApi from './useApi';
import useFilterQuery from './useFilterQuery';
import useModified from '../useModified';
export function useTeamMembers(teamId: string) {
const { get } = useApi();
const { modified } = useModified(`teams:members`);
return useFilterQuery({
queryKey: ['teams:members', { teamId, modified }],
queryFn: (params: any) => {
return get(`/teams/${teamId}/users`, params);
},
enabled: !!teamId,
});
}
export default useTeamMembers;

View file

@ -0,0 +1,17 @@
import useApi from './useApi';
import useFilterQuery from './useFilterQuery';
import useModified from '../useModified';
export function useTeamWebsites(teamId: string) {
const { get } = useApi();
const { modified } = useModified(`teams:websites`);
return useFilterQuery({
queryKey: ['teams:websites', { teamId, modified }],
queryFn: (params: any) => {
return get(`/teams/${teamId}/websites`, params);
},
});
}
export default useTeamWebsites;

View file

@ -0,0 +1,20 @@
import useApi from './useApi';
import useFilterQuery from './useFilterQuery';
import useLogin from './useLogin';
import useModified from '../useModified';
export function useTeams(userId?: string) {
const { get } = useApi();
const { user } = useLogin();
const id = userId || user?.id;
const { modified } = useModified(`teams`);
return useFilterQuery({
queryKey: ['teams', { userId: id, modified }],
queryFn: (params: any) => {
return get(`/teams`, params);
},
});
}
export default useTeams;

View file

@ -0,0 +1,13 @@
import useApi from './useApi';
export function useUser(userId: string, options?: { [key: string]: any }) {
const { get, useQuery } = useApi();
return useQuery({
queryKey: ['users', userId],
queryFn: () => get(`/users/${userId}`),
enabled: !!userId,
...options,
});
}
export default useUser;

View file

@ -0,0 +1,19 @@
import useApi from './useApi';
import useFilterQuery from './useFilterQuery';
import useModified from '../useModified';
export function useUsers() {
const { get } = useApi();
const { modified } = useModified(`users`);
return useFilterQuery({
queryKey: ['users', { modified }],
queryFn: (params: any) => {
return get('/admin/users', {
...params,
});
},
});
}
export default useUsers;

View file

@ -1,11 +1,13 @@
import useApi from './useApi';
export function useWebsite(websiteId: string) {
export function useWebsite(websiteId: string, options?: { [key: string]: any }) {
const { get, useQuery } = useApi();
return useQuery({
queryKey: ['websites', websiteId],
queryKey: ['website', { websiteId }],
queryFn: () => get(`/websites/${websiteId}`),
enabled: !!websiteId,
...options,
});
}

View file

@ -0,0 +1,19 @@
import useApi from './useApi';
import { UseQueryOptions } from '@tanstack/react-query';
export function useWebsiteEvents(
websiteId: string,
params?: { [key: string]: any },
options?: Omit<UseQueryOptions, 'queryKey' | 'queryFn'>,
) {
const { get, useQuery } = useApi();
return useQuery({
queryKey: ['events', { ...params }],
queryFn: () => get(`/websites/${websiteId}/events`, { ...params }),
enabled: !!websiteId,
...options,
});
}
export default useWebsiteEvents;

View file

@ -0,0 +1,36 @@
import useApi from './useApi';
import { UseQueryOptions } from '@tanstack/react-query';
export function useWebsiteMetrics(
websiteId: string,
params?: { [key: string]: any },
options?: Omit<UseQueryOptions & { onDataLoad?: (data: any) => void }, 'queryKey' | 'queryFn'>,
) {
const { get, useQuery } = useApi();
return useQuery({
queryKey: [
'websites:metrics',
{
websiteId,
...params,
},
],
queryFn: async () => {
const filters = { ...params };
filters[params.type] = undefined;
const data = await get(`/websites/${websiteId}/metrics`, {
...filters,
});
options?.onDataLoad?.(data);
return data;
},
...options,
});
}
export default useWebsiteMetrics;

View file

@ -0,0 +1,25 @@
import { useApi } from './useApi';
import { useFilterQuery } from './useFilterQuery';
import { useLogin } from './useLogin';
import useModified from '../useModified';
export function useWebsites(
{ userId, teamId }: { userId?: string; teamId?: string },
params?: { [key: string]: string | number },
) {
const { get } = useApi();
const { user } = useLogin();
const { modified } = useModified(`websites`);
return useFilterQuery({
queryKey: ['websites', { userId, teamId, modified, ...params }],
queryFn: (data: any) => {
return get(teamId ? `/teams/${teamId}/websites` : `/users/${userId || user.id}/websites`, {
...data,
...params,
});
},
});
}
export default useWebsites;

View file

@ -1,6 +1,6 @@
import { useState, useEffect } from 'react';
import { httpGet } from 'next-basics';
import enUS from 'public/intl/country/en-US.json';
import enUS from '../../../public/intl/country/en-US.json';
const countryNames = {
'en-US': enUS,

View file

@ -4,10 +4,10 @@ import { DATE_RANGE_CONFIG, DEFAULT_DATE_RANGE } from 'lib/constants';
import websiteStore, { setWebsiteDateRange } from 'store/websites';
import appStore, { setDateRange } from 'store/app';
import { DateRange } from 'lib/types';
import useLocale from './useLocale';
import useApi from './useApi';
import { useLocale } from './useLocale';
import { useApi } from './queries/useApi';
export function useDateRange(websiteId?: string) {
export function useDateRange(websiteId?: string): [DateRange, (value: string | DateRange) => void] {
const { get } = useApi();
const { locale } = useLocale();
const websiteConfig = websiteStore(state => state[websiteId]?.dateRange);
@ -45,10 +45,7 @@ export function useDateRange(websiteId?: string) {
}
};
return [dateRange, saveDateRange] as [
{ startDate: Date; endDate: Date; modified?: number },
(value: string | DateRange) => void,
];
return [dateRange, saveDateRange];
}
export default useDateRange;

View file

@ -2,7 +2,7 @@ import useMessages from './useMessages';
import { BROWSERS, OS_NAMES } from 'lib/constants';
import useLocale from './useLocale';
import useCountryNames from './useCountryNames';
import regions from 'public/iso-3166-2.json';
import regions from '../../../public/iso-3166-2.json';
export function useFormat() {
const { formatMessage, labels } = useMessages();
@ -31,7 +31,7 @@ export function useFormat() {
};
const formatCity = (value: string, country?: string): string => {
return `${value}, ${countryNames[country]}`;
return countryNames[country] ? `${value}, ${countryNames[country]}` : value;
};
const formatValue = (value: string, type: string, data?: { [key: string]: any }): string => {

View file

@ -1,6 +1,6 @@
import { useState, useEffect } from 'react';
import { httpGet } from 'next-basics';
import enUS from 'public/intl/language/en-US.json';
import enUS from '../../../public/intl/language/en-US.json';
const languageNames = {
'en-US': enUS,

View file

@ -3,14 +3,14 @@ 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/country/en-US.json';
import { useForceUpdate } from './useForceUpdate';
import enUS from '../../../public/intl/country/en-US.json';
const messages = {
'en-US': enUS,
};
const selector = state => state.locale;
const selector = (state: { locale: any }) => state.locale;
export function useLocale() {
const locale = useStore(selector);
@ -18,7 +18,7 @@ export function useLocale() {
const dir = getTextDirection(locale);
const dateLocale = getDateLocale(locale);
async function loadMessages(locale) {
async function loadMessages(locale: string) {
const { ok, data } = await httpGet(`${process.env.basePath}/intl/messages/${locale}.json`);
if (ok) {
@ -26,7 +26,7 @@ export function useLocale() {
}
}
async function saveLocale(value) {
async function saveLocale(value: string) {
if (!messages[value]) {
await loadMessages(value);
}

View file

@ -1,22 +0,0 @@
import useApi from 'components/hooks/useApi';
import useUser from 'components/hooks/useUser';
export function useLogin() {
const { get, useQuery } = useApi();
const { user, setUser } = useUser();
const query = useQuery({
queryKey: ['login'],
queryFn: async () => {
const data = await get('/auth/verify');
setUser(data);
return data;
},
});
return { user, ...query };
}
export default useLogin;

View file

@ -1,6 +1,5 @@
import { useIntl, FormattedMessage, MessageDescriptor, PrimitiveType } from 'react-intl';
import { useIntl, FormattedMessage } from 'react-intl';
import { messages, labels } from 'components/messages';
import { FormatXMLElementFn, Options } from 'intl-messageformat';
export function useMessages(): any {
const intl = useIntl();
@ -12,14 +11,12 @@ export function useMessages(): any {
};
const formatMessage = (
descriptor:
| MessageDescriptor
| {
id: string;
defaultMessage: string;
},
values?: Record<string, PrimitiveType | FormatXMLElementFn<string, string>>,
opts?: Options,
descriptor: {
id: string;
defaultMessage: string;
},
values?: { [key: string]: string },
opts?: any,
) => {
return descriptor ? intl.formatMessage(descriptor, values, opts) : null;
};

View file

@ -0,0 +1,15 @@
import useStore from 'store/modified';
export function useModified(key?: string) {
const modified = useStore(state => state?.[key]);
const touch = (id?: string) => {
if (id || key) {
useStore.setState({ [id || key]: Date.now() });
}
};
return { modified, touch };
}
export default useModified;

View file

@ -6,7 +6,7 @@ export function useNavigation(): {
pathname: string;
query: { [key: string]: string };
router: any;
makeUrl: (params: any, reset?: boolean) => string;
renderUrl: (params: any, reset?: boolean) => string;
} {
const router = useRouter();
const pathname = usePathname();
@ -22,11 +22,11 @@ export function useNavigation(): {
return obj;
}, [params]);
function makeUrl(params: any, reset?: boolean) {
function renderUrl(params: any, reset?: boolean) {
return reset ? pathname : buildUrl(pathname, { ...query, ...params });
}
return { pathname, query, router, makeUrl };
return { pathname, query, router, renderUrl };
}
export default useNavigation;

View file

@ -1,30 +0,0 @@
import { useState } from 'react';
import useApi from './useApi';
import useFilterQuery from 'components/hooks/useFilterQuery';
export function useReports(websiteId?: string) {
const [modified, setModified] = useState(Date.now());
const { get, del, useMutation } = useApi();
const { mutate } = useMutation({ mutationFn: (reportId: string) => del(`/reports/${reportId}`) });
const queryResult = useFilterQuery({
queryKey: ['reports', { websiteId, modified }],
queryFn: (params: any) => {
return get(websiteId ? `/websites/${websiteId}/reports` : `/reports`, params);
},
});
const deleteReport = (id: any) => {
mutate(id, {
onSuccess: () => {
setModified(Date.now());
},
});
};
return {
...queryResult,
deleteReport,
};
}
export default useReports;

View file

@ -0,0 +1,17 @@
import { usePathname } from 'next/navigation';
export function useTeamUrl(): {
teamId?: string;
renderTeamUrl: (url: string) => string;
} {
const pathname = usePathname();
const [, teamId] = pathname.match(/^\/teams\/([a-f0-9-]+)/) || [];
function renderTeamUrl(url: string) {
return teamId ? `/teams/${teamId}${url}` : url;
}
return { teamId, renderTeamUrl };
}
export default useTeamUrl;

View file

@ -7,7 +7,7 @@ export function useTimezone() {
const [timezone, setTimezone] = useState(getItem(TIMEZONE_CONFIG) || getTimezone());
const saveTimezone = useCallback(
value => {
(value: string) => {
setItem(TIMEZONE_CONFIG, value);
setTimezone(value);
},

View file

@ -1,11 +0,0 @@
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

@ -4,6 +4,7 @@ import Bars from 'assets/bars.svg';
import BarChart from 'assets/bar-chart.svg';
import Bolt from 'assets/bolt.svg';
import Calendar from 'assets/calendar.svg';
import Change from 'assets/change.svg';
import Clock from 'assets/clock.svg';
import Dashboard from 'assets/dashboard.svg';
import Eye from 'assets/eye.svg';
@ -29,6 +30,7 @@ const icons = {
BarChart,
Bolt,
Calendar,
Change,
Clock,
Dashboard,
Eye,

View file

@ -2,34 +2,34 @@ import { useState } from 'react';
import { Icon, Modal, Dropdown, Item, Text, Flexbox } from 'react-basics';
import { endOfYear, isSameDay } from 'date-fns';
import DatePickerForm from 'components/metrics/DatePickerForm';
import useLocale from 'components/hooks/useLocale';
import useMessages from 'components/hooks/useMessages';
import { useLocale, useMessages } from 'components/hooks';
import Icons from 'components/icons';
import { formatDate } from 'lib/date';
import { formatDate, parseDateValue } from 'lib/date';
export interface DateFilterProps {
value: string;
startDate: Date;
endDate: Date;
offset?: number;
className?: string;
onChange?: (value: string) => void;
selectedUnit?: string;
showAllTime?: boolean;
alignment?: 'start' | 'center' | 'end';
}
export function DateFilter({
value,
startDate,
endDate,
value,
offset = 0,
className,
onChange,
selectedUnit,
showAllTime = false,
alignment = 'end',
}: DateFilterProps) {
const { formatMessage, labels } = useMessages();
const [showPicker, setShowPicker] = useState(false);
const { locale } = useLocale();
const options = [
{ label: formatMessage(labels.today), value: '1day' },
@ -76,19 +76,6 @@ export function DateFilter({
},
].filter(n => n);
const renderValue = (value: string) => {
return value.startsWith('range') ? (
<CustomRange
startDate={startDate}
endDate={endDate}
selectedUnit={selectedUnit}
onClick={() => handleChange('custom')}
/>
) : (
options.find(e => e.value === value).label
);
};
const handleChange = (value: string) => {
if (value === 'custom') {
setShowPicker(true);
@ -104,6 +91,31 @@ export function DateFilter({
const handleClose = () => setShowPicker(false);
const renderValue = (value: string) => {
const { unit } = parseDateValue(value);
if (offset && unit === 'year') {
return formatDate(startDate, 'yyyy', locale);
}
if (offset && unit === 'month') {
return formatDate(startDate, 'MMMM yyyy', locale);
}
if (value.startsWith('range') || offset) {
return (
<CustomRange
startDate={startDate}
endDate={endDate}
unit={unit}
onClick={() => handleChange('custom')}
/>
);
}
return options.find(e => e.value === value).label;
};
return (
<>
<Dropdown
@ -137,10 +149,10 @@ export function DateFilter({
);
}
const CustomRange = ({ startDate, endDate, selectedUnit, onClick }) => {
const CustomRange = ({ startDate, endDate, unit, onClick }) => {
const { locale } = useLocale();
const monthFormat = +selectedUnit?.num === 1 && selectedUnit?.unit === 'month';
const monthFormat = unit === 'month';
function handleClick(e) {
e.stopPropagation();

View file

@ -1,7 +1,7 @@
import { Icon, Button, PopupTrigger, Popup, Text } from 'react-basics';
import classNames from 'classnames';
import { languages } from 'lib/lang';
import useLocale from 'components/hooks/useLocale';
import { useLocale } from 'components/hooks';
import Icons from 'components/icons';
import styles from './LanguageButton.module.css';

View file

@ -1,6 +1,6 @@
import { Button, Icon, Icons, TooltipPopup } from 'react-basics';
import Link from 'next/link';
import useMessages from 'components/hooks/useMessages';
import { useMessages } from 'components/hooks';
export function LogoutButton({
tooltipPosition = 'top',
@ -9,7 +9,7 @@ export function LogoutButton({
}) {
const { formatMessage, labels } = useMessages();
return (
<Link href="/src/app/logout/logout">
<Link href="/src/app/logout/LogoutPage">
<TooltipPopup label={formatMessage(labels.logout)} position={tooltipPosition}>
<Button variant="quiet">
<Icon>

View file

@ -1,6 +1,10 @@
.menu {
width: 200px;
z-index: var(--z-index-popup);
border: 1px solid var(--border-color);
border-radius: var(--border-radius);
overflow: hidden;
background: var(--base50);
}
.item {
@ -16,3 +20,9 @@
text-align: right;
margin-right: 10px;
}
.name {
color: var(--font-color200);
background: var(--base75);
padding: var(--size300) var(--size600);
}

View file

@ -1,26 +1,26 @@
import { Key } from 'react';
import { Icon, Button, PopupTrigger, Popup, Menu, Item, Text } from 'react-basics';
import { useRouter } from 'next/navigation';
import Icons from 'components/icons';
import useMessages from 'components/hooks/useMessages';
import useUser from 'components/hooks/useUser';
import useLocale from 'components/hooks/useLocale';
import { useMessages, useLogin, useLocale } from 'components/hooks';
import { CURRENT_VERSION } from 'lib/constants';
import styles from './ProfileButton.module.css';
export function ProfileButton() {
const { formatMessage, labels } = useMessages();
const { user } = useUser();
const { user } = useLogin();
const router = useRouter();
const { dir } = useLocale();
const cloudMode = Boolean(process.env.cloudMode);
const handleSelect = key => {
const handleSelect = (key: Key, close: () => void) => {
if (key === 'profile') {
router.push('/settings/profile');
router.push('/profile');
}
if (key === 'logout') {
router.push('/logout');
}
close();
};
return (
@ -29,31 +29,28 @@ export function ProfileButton() {
<Icon>
<Icons.Profile />
</Icon>
<Icon size="sm">
<Icons.ChevronDown />
</Icon>
</Button>
<Popup position="bottom" alignment={dir === 'rtl' ? 'start' : 'end'}>
<Menu variant="popup" onSelect={handleSelect} className={styles.menu}>
<Item key="user" className={styles.item}>
<Text>{user.username}</Text>
</Item>
<Item key="profile" className={styles.item} divider={true}>
<Icon>
<Icons.User />
</Icon>
<Text>{formatMessage(labels.profile)}</Text>
</Item>
{!cloudMode && (
<Item key="logout" className={styles.item}>
{(close: () => void) => (
<Menu onSelect={key => handleSelect(key, close)} className={styles.menu}>
<Text className={styles.name}>{user.username}</Text>
<Item key="profile" className={styles.item} divider={true}>
<Icon>
<Icons.Logout />
<Icons.User />
</Icon>
<Text>{formatMessage(labels.logout)}</Text>
<Text>{formatMessage(labels.profile)}</Text>
</Item>
)}
<div className={styles.version}>{`v${CURRENT_VERSION}`}</div>
</Menu>
{!cloudMode && (
<Item key="logout" className={styles.item}>
<Icon>
<Icons.Logout />
</Icon>
<Text>{formatMessage(labels.logout)}</Text>
</Item>
)}
<div className={styles.version}>{`v${CURRENT_VERSION}`}</div>
</Menu>
)}
</Popup>
</PopupTrigger>
);

View file

@ -1,8 +1,8 @@
import { LoadingButton, Icon, TooltipPopup } from 'react-basics';
import { setWebsiteDateRange } from 'store/websites';
import useDateRange from 'components/hooks/useDateRange';
import { useDateRange } from 'components/hooks';
import Icons from 'components/icons';
import useMessages from 'components/hooks/useMessages';
import { useMessages } from 'components/hooks';
export function RefreshButton({
websiteId,

View file

@ -1,8 +1,8 @@
import { Button, Icon, PopupTrigger, Popup, Form, FormRow } from 'react-basics';
import TimezoneSetting from 'app/(main)/settings/profile/TimezoneSetting';
import DateRangeSetting from 'app/(main)/settings/profile/DateRangeSetting';
import TimezoneSetting from 'app/(main)/profile/TimezoneSetting';
import DateRangeSetting from 'app/(main)/profile/DateRangeSetting';
import Icons from 'components/icons';
import useMessages from 'components/hooks/useMessages';
import { useMessages } from 'components/hooks';
import styles from './SettingsButton.module.css';
export function SettingsButton() {

View file

@ -0,0 +1,20 @@
.button {
font-weight: 700;
}
.menu {
background: var(--base50);
}
.heading {
color: var(--base600);
font-size: 10px;
font-weight: 700;
padding: 8px 16px;
text-transform: uppercase;
border-bottom: 1px solid var(--base300);
}
.selected {
font-weight: bold;
}

View file

@ -0,0 +1,60 @@
import { Key } from 'react';
import { Text, Icon, Button, Popup, Menu, Item, PopupTrigger, Flexbox } from 'react-basics';
import classNames from 'classnames';
import Icons from 'components/icons';
import { useLogin, useMessages, useNavigation } from 'components/hooks';
import styles from './TeamsButton.module.css';
export function TeamsButton({ teamId }: { teamId: string }) {
const { user } = useLogin();
const { formatMessage, labels } = useMessages();
const { router } = useNavigation();
const team = user?.teams?.find(({ id }) => id === teamId);
const cloudMode = !!process.env.cloudMode;
const handleSelect = (close: () => void, id: Key) => {
if (id !== user.id) {
router.push(cloudMode ? `${process.env.cloudUrl}/teams/${id}` : `/teams/${id}`);
} else {
router.push('/');
}
close();
};
return (
<PopupTrigger>
<Button className={styles.button} variant="quiet">
<Icon>{teamId ? <Icons.Users /> : <Icons.User />}</Icon>
<Text>{teamId ? team?.name : user.username}</Text>
</Button>
<Popup alignment="end">
{(close: () => void) => (
<Menu variant="popup" onSelect={handleSelect.bind(null, close)}>
<div className={styles.heading}>{formatMessage(labels.myAccount)}</div>
<Item key={user.id} className={classNames({ [styles.selected]: !teamId })}>
<Flexbox gap={10} alignItems="center">
<Icon>
<Icons.User />
</Icon>
<Text>{user.username}</Text>
</Flexbox>
</Item>
<div className={styles.heading}>{formatMessage(labels.team)}</div>
{user?.teams?.map(({ id, name }) => (
<Item key={id} className={classNames({ [styles.selected]: id === teamId })}>
<Flexbox gap={10} alignItems="center">
<Icon>
<Icons.Users />
</Icon>
<Text>{name}</Text>
</Flexbox>
</Item>
))}
</Menu>
)}
</Popup>
</PopupTrigger>
);
}
export default TeamsButton;

View file

@ -1,6 +1,6 @@
import { useTransition, animated } from '@react-spring/web';
import { Button, Icon } from 'react-basics';
import useTheme from 'components/hooks/useTheme';
import { useTheme } from 'components/hooks';
import Icons from 'components/icons';
import styles from './ThemeButton.module.css';

View file

@ -1,36 +1,35 @@
import useDateRange from 'components/hooks/useDateRange';
import { useDateRange } from 'components/hooks';
import { isAfter } from 'date-fns';
import { incrementDateRange } from 'lib/date';
import { getOffsetDateRange } from 'lib/date';
import { Button, Icon, Icons } from 'react-basics';
import DateFilter from './DateFilter';
import styles from './WebsiteDateFilter.module.css';
import { DateRange } from 'lib/types';
export function WebsiteDateFilter({ websiteId }: { websiteId: string }) {
const [dateRange, setDateRange] = useDateRange(websiteId);
const { value, startDate, endDate, selectedUnit } = dateRange;
const isFutureDate =
value !== 'all' &&
selectedUnit &&
isAfter(incrementDateRange(dateRange, -1).startDate, new Date());
const { value, startDate, endDate, offset } = dateRange;
const disableForward =
value === 'all' || isAfter(getOffsetDateRange(dateRange, 1).startDate, new Date());
const handleChange = value => {
const handleChange = (value: string | DateRange) => {
setDateRange(value);
};
const handleIncrement = value => {
setDateRange(incrementDateRange(dateRange, value));
const handleIncrement = (increment: number) => {
setDateRange(getOffsetDateRange(dateRange, increment));
};
return (
<div className={styles.container}>
{value !== 'all' && selectedUnit && (
{value !== 'all' && (
<div className={styles.buttons}>
<Button onClick={() => handleIncrement(1)}>
<Button onClick={() => handleIncrement(-1)}>
<Icon rotate={90}>
<Icons.ChevronDown />
</Icon>
</Button>
<Button onClick={() => handleIncrement(-1)} disabled={isFutureDate}>
<Button onClick={() => handleIncrement(1)} disabled={disableForward}>
<Icon rotate={270}>
<Icons.ChevronDown />
</Icon>
@ -42,7 +41,7 @@ export function WebsiteDateFilter({ websiteId }: { websiteId: string }) {
value={value}
startDate={startDate}
endDate={endDate}
selectedUnit={selectedUnit}
offset={offset}
onChange={handleChange}
showAllTime={true}
/>

View file

@ -1,35 +1,58 @@
import { useState, Key } from 'react';
import { Dropdown, Item } from 'react-basics';
import useApi from 'components/hooks/useApi';
import useMessages from 'components/hooks/useMessages';
import { useWebsite, useWebsites, useMessages } from 'components/hooks';
import Empty from 'components/common/Empty';
import styles from './WebsiteSelect.module.css';
export function WebsiteSelect({
websiteId,
teamId,
userId,
onSelect,
}: {
websiteId: string;
websiteId?: string;
teamId?: string;
userId?: string;
onSelect?: (key: any) => void;
}) {
const { formatMessage, labels } = useMessages();
const { get, useQuery } = useApi();
const { data } = useQuery({
queryKey: ['websites:me'],
queryFn: () => get('/me/websites', { pageSize: 100 }),
});
const { formatMessage, labels, messages } = useMessages();
const [query, setQuery] = useState('');
const [selectedId, setSelectedId] = useState<Key>(websiteId);
const renderValue = value => {
return data?.data?.find(({ id }) => id === value)?.name;
const { data: website } = useWebsite(selectedId as string);
const queryResult = useWebsites({ teamId, userId }, { query, pageSize: 5 });
const renderValue = () => {
return website?.name;
};
const renderEmpty = () => {
return <Empty message={formatMessage(messages.noResultsFound)} />;
};
const handleSelect = (value: any) => {
setSelectedId(value);
onSelect?.(value);
};
const handleSearch = (value: string) => {
setQuery(value);
};
return (
<Dropdown
menuProps={{ className: styles.dropdown }}
items={data?.data}
value={websiteId}
items={queryResult?.result?.data as any[]}
value={selectedId as string}
renderValue={renderValue}
onChange={onSelect}
renderEmpty={renderEmpty}
onChange={handleSelect}
alignment="end"
placeholder={formatMessage(labels.selectWebsite)}
allowSearch={true}
onSearch={handleSearch}
isLoading={queryResult.query.isLoading}
>
{({ id, name }) => <Item key={id}>{name}</Item>}
</Dropdown>

View file

@ -0,0 +1,31 @@
.layout {
display: grid;
grid-template-columns: max-content 1fr;
gap: 20px;
}
.menu {
width: 240px;
padding-top: 34px;
padding-right: 20px;
}
.content {
display: flex;
flex-direction: column;
min-height: 50vh;
}
@media only screen and (max-width: 992px) {
.layout {
grid-template-columns: 1fr;
}
.menu {
display: none;
}
.content {
margin-top: 20px;
}
}

View file

@ -0,0 +1,24 @@
import { ReactNode } from 'react';
import { usePathname } from 'next/navigation';
import SideNav from 'components/layout/SideNav';
import styles from './MenuLayout.module.css';
export function MenuLayout({ items = [], children }: { items: any[]; children: ReactNode }) {
const pathname = usePathname();
const cloudMode = !!process.env.cloudMode;
const getKey = () => items.find(({ url }) => pathname === url)?.key;
return (
<div className={styles.layout}>
{!cloudMode && (
<div className={styles.menu}>
<SideNav items={items} shallow={true} selectedKey={getKey()} />
</div>
)}
<div className={styles.content}>{children}</div>
</div>
);
}
export default MenuLayout;

View file

@ -2,7 +2,7 @@
import { ReactNode } from 'react';
import classNames from 'classnames';
import { Banner, Loading } from 'react-basics';
import useMessages from 'components/hooks/useMessages';
import { useMessages } from 'components/hooks';
import styles from './Page.module.css';
export function Page({

View file

@ -27,6 +27,11 @@
flex: 1;
}
.icon {
color: var(--base700);
margin-right: 1rem;
}
.actions {
display: flex;
justify-content: flex-end;

View file

@ -1,16 +1,26 @@
import classNames from 'classnames';
import React, { ReactNode } from 'react';
import { Icon } from 'react-basics';
import styles from './PageHeader.module.css';
export interface PageHeaderProps {
export function PageHeader({
title,
icon,
className,
children,
}: {
title?: ReactNode;
icon?: ReactNode;
className?: string;
children?: ReactNode;
}
export function PageHeader({ title, className, children }: PageHeaderProps) {
}) {
return (
<div className={classNames(styles.header, className)}>
{icon && (
<Icon size="lg" className={styles.icon}>
{icon}
</Icon>
)}
{title && <div className={styles.title}>{title}</div>}
<div className={styles.actions}>{children}</div>
</div>

View file

@ -1,6 +1,7 @@
.menu {
display: flex;
flex-direction: column;
gap: 4px;
}
.item a {

View file

@ -1,6 +1,7 @@
import { defineMessages } from 'react-intl';
export const labels = defineMessages({
ok: { id: 'label.ok', defaultMessage: 'OK' },
unknown: { id: 'label.unknown', defaultMessage: 'Unknown' },
required: { id: 'label.required', defaultMessage: 'Required' },
save: { id: 'label.save', defaultMessage: 'Save' },
@ -16,7 +17,8 @@ export const labels = defineMessages({
role: { id: 'label.role', defaultMessage: 'Role' },
user: { id: 'label.user', defaultMessage: 'User' },
viewOnly: { id: 'label.view-only', defaultMessage: 'View only' },
admin: { id: 'label.admin', defaultMessage: 'Administrator' },
manage: { id: 'label.manage', defaultMessage: 'Manage' },
administrator: { id: 'label.administrator', defaultMessage: 'Administrator' },
confirm: { id: 'label.confirm', defaultMessage: 'Confirm' },
details: { id: 'label.details', defaultMessage: 'Details' },
website: { id: 'label.website', defaultMessage: 'Website' },
@ -26,6 +28,7 @@ export const labels = defineMessages({
created: { id: 'label.created', defaultMessage: 'Created' },
edit: { id: 'label.edit', defaultMessage: 'Edit' },
name: { id: 'label.name', defaultMessage: 'Name' },
member: { id: 'label.member', defaultMessage: 'Member' },
members: { id: 'label.members', defaultMessage: 'Members' },
accessCode: { id: 'label.access-code', defaultMessage: 'Access code' },
teamId: { id: 'label.team-id', defaultMessage: 'Team ID' },
@ -40,7 +43,7 @@ export const labels = defineMessages({
owner: { id: 'label.owner', defaultMessage: 'Owner' },
teamOwner: { id: 'label.team-owner', defaultMessage: 'Team owner' },
teamMember: { id: 'label.team-member', defaultMessage: 'Team member' },
teamGuest: { id: 'label.team-guest', defaultMessage: 'Team guest' },
teamViewOnly: { id: 'label.team-view-only', defaultMessage: 'Team view only' },
enableShareUrl: { id: 'label.enable-share-url', defaultMessage: 'Enable share URL' },
data: { id: 'label.data', defaultMessage: 'Data' },
trackingCode: { id: 'label.tracking-code', defaultMessage: 'Tracking code' },
@ -50,8 +53,13 @@ export const labels = defineMessages({
websiteId: { id: 'label.website-id', defaultMessage: 'Website ID' },
resetWebsite: { id: 'label.reset-website', defaultMessage: 'Reset website' },
deleteWebsite: { id: 'label.delete-website', defaultMessage: 'Delete website' },
transferWebsite: { id: 'label.transfer-website', defaultMessage: 'Transfer website' },
deleteReport: { id: 'label.delete-report', defaultMessage: 'Delete report' },
reset: { id: 'label.reset', defaultMessage: 'Reset' },
addWebsite: { id: 'label.add-website', defaultMessage: 'Add website' },
addMember: { id: 'label.add-member', defaultMessage: 'Add member' },
editMember: { id: 'label.edit-member', defaultMessage: 'Edit member' },
removeMember: { id: 'label.remove-member', defaultMessage: 'Remove member' },
addDescription: { id: 'label.add-description', defaultMessage: 'Add description' },
changePassword: { id: 'label.change-password', defaultMessage: 'Change password' },
currentPassword: { id: 'label.current-password', defaultMessage: 'Current password' },
@ -105,6 +113,7 @@ export const labels = defineMessages({
allTime: { id: 'label.all-time', defaultMessage: 'All time' },
customRange: { id: 'label.custom-range', defaultMessage: 'Custom range' },
selectWebsite: { id: 'label.select-website', defaultMessage: 'Select website' },
selectRole: { id: 'label.select-role', defaultMessage: 'Select role' },
selectDate: { id: 'label.select-date', defaultMessage: 'Select date' },
all: { id: 'label.all', defaultMessage: 'All' },
sessions: { id: 'label.sessions', defaultMessage: 'Sessions' },
@ -197,6 +206,9 @@ export const labels = defineMessages({
id: 'label.number-of-records',
defaultMessage: '{x} {x, plural, one {record} other {records}}',
},
select: { id: 'label.select', defaultMessage: 'Select' },
myAccount: { id: 'label.my-account', defaultMessage: 'My account' },
transfer: { id: 'label.transfer', defaultMessage: 'Transfer' },
});
export const messages = defineMessages({
@ -213,6 +225,10 @@ export const messages = defineMessages({
id: 'message.confirm-delete',
defaultMessage: 'Are you sure you want to delete {target}?',
},
confirmRemove: {
id: 'message.confirm-remove',
defaultMessage: 'Are you sure you want to remove {target}?',
},
confirmLeave: {
id: 'message.confirm-leave',
defaultMessage: 'Are you sure you want to leave {target}?',
@ -227,7 +243,7 @@ export const messages = defineMessages({
},
shareUrl: {
id: 'message.share-url',
defaultMessage: 'Your website stats are publically available at the following URL:',
defaultMessage: 'Your website stats are publicly available at the following URL:',
},
trackingCode: {
id: 'message.tracking-code',
@ -238,13 +254,9 @@ export const messages = defineMessages({
id: 'message.team-already-member',
defaultMessage: 'You are already a member of the team.',
},
deleteAccount: {
id: 'message.delete-account',
defaultMessage: 'To delete this account, type {confirmation} in the box below to confirm.',
},
deleteWebsite: {
id: 'message.delete-website',
defaultMessage: 'To delete this website, type {confirmation} in the box below to confirm.',
actionConfirmation: {
id: 'message.action-confirmation',
defaultMessage: 'Type {confirmation} in the box below to confirm.',
},
resetWebsite: {
id: 'message.reset-website',
@ -263,6 +275,10 @@ export const messages = defineMessages({
id: 'message.delete-website-warning',
defaultMessage: 'All website data will be deleted.',
},
deleteTeamWarning: {
id: 'message.delete-team-warning',
defaultMessage: 'Deleting a team will also delete all team websites.',
},
noResultsFound: {
id: 'message.no-results-found',
defaultMessage: 'No results found.',
@ -312,4 +328,16 @@ export const messages = defineMessages({
id: 'message.new-version-available',
defaultMessage: 'A new version of Umami {version} is available!',
},
transferWebsite: {
id: 'message.transfer-website',
defaultMessage: 'Transfer website ownership to your account or another team.',
},
transferTeamWebsiteToUser: {
id: 'message.transfer-team-website-to-user',
defaultMessage: 'Transfer this website to your account?',
},
transferUserWebsiteToTeam: {
id: 'message.transfer-user-website-to-team',
defaultMessage: 'Select the team to transfer this website to.',
},
});

View file

@ -1,7 +1,7 @@
import { useMemo } from 'react';
import { StatusLight } from 'react-basics';
import useApi from 'components/hooks/useApi';
import useMessages from 'components/hooks/useMessages';
import { useApi } from 'components/hooks';
import { useMessages } from 'components/hooks';
import styles from './ActiveUsers.module.css';
export function ActiveUsers({

View file

@ -4,8 +4,8 @@ import classNames from 'classnames';
import Chart from 'chart.js/auto';
import HoverTooltip from 'components/common/HoverTooltip';
import Legend from 'components/metrics/Legend';
import useLocale from 'components/hooks/useLocale';
import useTheme from 'components/hooks/useTheme';
import { useLocale } from 'components/hooks';
import { useTheme } from 'components/hooks';
import { DEFAULT_ANIMATION_DURATION } from 'lib/constants';
import { renderNumberLabels } from 'lib/charts';
import styles from './BarChart.module.css';

View file

@ -1,7 +1,7 @@
import FilterLink from 'components/common/FilterLink';
import MetricsTable, { MetricsTableProps } from 'components/metrics/MetricsTable';
import useMessages from 'components/hooks/useMessages';
import useFormat from 'components/hooks/useFormat';
import { useMessages } from 'components/hooks';
import { useFormat } from 'components/hooks';
export function BrowsersTable(props: MetricsTableProps) {
const { formatMessage, labels } = useMessages();

View file

@ -1,9 +1,9 @@
import MetricsTable, { MetricsTableProps } from './MetricsTable';
import { emptyFilter } from 'lib/filters';
import FilterLink from 'components/common/FilterLink';
import useLocale from 'components/hooks/useLocale';
import useMessages from 'components/hooks/useMessages';
import useCountryNames from 'components/hooks/useCountryNames';
import { useLocale } from 'components/hooks';
import { useMessages } from 'components/hooks';
import { useCountryNames } from 'components/hooks';
export function CitiesTable(props: MetricsTableProps) {
const { locale } = useLocale();

View file

@ -1,5 +1,5 @@
import FilterLink from 'components/common/FilterLink';
import useCountryNames from 'components/hooks/useCountryNames';
import { useCountryNames } from 'components/hooks';
import { useLocale, useMessages, useFormat } from 'components/hooks';
import MetricsTable, { MetricsTableProps } from './MetricsTable';

View file

@ -1,9 +1,9 @@
import { useState } from 'react';
import { Button, ButtonGroup, Calendar } from 'react-basics';
import { isAfter, isBefore, isSameDay, startOfDay, endOfDay } from 'date-fns';
import useLocale from 'components/hooks/useLocale';
import { useLocale } from 'components/hooks';
import { FILTER_DAY, FILTER_RANGE } from 'lib/constants';
import useMessages from 'components/hooks/useMessages';
import { useMessages } from 'components/hooks';
import styles from './DatePickerForm.module.css';
export function DatePickerForm({

View file

@ -1,6 +1,6 @@
import MetricsTable, { MetricsTableProps } from './MetricsTable';
import FilterLink from 'components/common/FilterLink';
import useMessages from 'components/hooks/useMessages';
import { useMessages } from 'components/hooks';
import { useFormat } from 'components/hooks';
export function DevicesTable(props: MetricsTableProps) {

View file

@ -3,7 +3,13 @@ import { Loading } from 'react-basics';
import { colord } from 'colord';
import BarChart from './BarChart';
import { getDateArray } from 'lib/date';
import { useApi, useLocale, useDateRange, useTimezone, useNavigation } from 'components/hooks';
import {
useLocale,
useDateRange,
useTimezone,
useNavigation,
useWebsiteEvents,
} from 'components/hooks';
import { EVENT_COLORS } from 'lib/constants';
import { renderDateLabels, renderStatusTooltipPopup } from 'lib/charts';
@ -14,34 +20,29 @@ export interface EventsChartProps {
}
export function EventsChart({ websiteId, className, token }: EventsChartProps) {
const { get, useQuery } = useApi();
const [{ startDate, endDate, unit, modified }] = useDateRange(websiteId);
const [{ startDate, endDate, unit, offset }] = useDateRange(websiteId);
const { locale } = useLocale();
const [timezone] = useTimezone();
const {
query: { url, event },
} = useNavigation();
const { data, isLoading } = useQuery({
queryKey: ['events', websiteId, modified, event],
queryFn: () =>
get(`/websites/${websiteId}/events`, {
startAt: +startDate,
endAt: +endDate,
unit,
timezone,
url,
event,
token,
}),
enabled: !!websiteId,
const { data, isLoading } = useWebsiteEvents(websiteId, {
startAt: +startDate,
endAt: +endDate,
unit,
timezone,
url,
event,
token,
offset,
});
const datasets = useMemo(() => {
if (!data) return [];
if (isLoading) return data;
const map = data.reduce((obj, { x, t, y }) => {
const map = (data as any[]).reduce((obj, { x, t, y }) => {
if (!obj[x]) {
obj[x] = [];
}
@ -75,12 +76,12 @@ export function EventsChart({ websiteId, className, token }: EventsChartProps) {
return (
<BarChart
className={className}
datasets={datasets}
datasets={datasets as any[]}
unit={unit}
loading={isLoading}
stacked
stacked={true}
renderXLabel={renderDateLabels(unit, locale)}
renderTooltipPopup={renderStatusTooltipPopup(unit, locale)}
isLoading={isLoading}
/>
);
}

View file

@ -1,5 +1,5 @@
import MetricsTable, { MetricsTableProps } from './MetricsTable';
import useMessages from 'components/hooks/useMessages';
import { useMessages } from 'components/hooks';
export function EventsTable(props: MetricsTableProps) {
const { formatMessage, labels } = useMessages();

View file

@ -1,8 +1,8 @@
import { safeDecodeURI } from 'next-basics';
import { Button, Icon, Icons, Text } from 'react-basics';
import useNavigation from 'components/hooks/useNavigation';
import useMessages from 'components/hooks/useMessages';
import useFormat from 'components/hooks/useFormat';
import { useNavigation } from 'components/hooks';
import { useMessages } from 'components/hooks';
import { useFormat } from 'components/hooks';
import styles from './FilterTags.module.css';
export function FilterTags({ params }) {
@ -10,7 +10,7 @@ export function FilterTags({ params }) {
const { formatValue } = useFormat();
const {
router,
makeUrl,
renderUrl,
query: { view },
} = useNavigation();
@ -19,11 +19,11 @@ export function FilterTags({ params }) {
}
function handleCloseFilter(param?: string) {
router.push(makeUrl({ [param]: undefined }));
router.push(renderUrl({ [param]: undefined }));
}
function handleResetFilter() {
router.push(makeUrl({ view }, true));
router.push(renderUrl({ view }, true));
}
return (

View file

@ -1,8 +1,8 @@
import MetricsTable, { MetricsTableProps } from './MetricsTable';
import { percentFilter } from 'lib/filters';
import useLanguageNames from 'components/hooks/useLanguageNames';
import useLocale from 'components/hooks/useLocale';
import useMessages from 'components/hooks/useMessages';
import { useLanguageNames } from 'components/hooks';
import { useLocale } from 'components/hooks';
import { useMessages } from 'components/hooks';
export function LanguagesTable({
onDataLoad,

View file

@ -2,15 +2,15 @@ import { useEffect } from 'react';
import { StatusLight } from 'react-basics';
import { colord } from 'colord';
import classNames from 'classnames';
import useLocale from 'components/hooks/useLocale';
import useForceUpdate from 'components/hooks/useForceUpdate';
import { useLocale } from 'components/hooks';
import { useForceUpdate } from 'components/hooks';
import styles from './Legend.module.css';
export function Legend({ chart }) {
const { locale } = useLocale();
const forceUpdate = useForceUpdate();
const handleClick = index => {
const handleClick = (index: string | number) => {
const meta = chart.getDatasetMeta(index);
meta.hidden = meta.hidden === null ? !chart.data.datasets[index].hidden : null;

View file

@ -3,7 +3,7 @@ import { useSpring, animated, config } from '@react-spring/web';
import classNames from 'classnames';
import Empty from 'components/common/Empty';
import { formatLongNumber } from 'lib/format';
import useMessages from 'components/hooks/useMessages';
import { useMessages } from 'components/hooks';
import styles from './ListTable.module.css';
import { ReactNode } from 'react';
@ -63,6 +63,7 @@ export function ListTable({
{data?.length === 0 && <Empty className={styles.empty} />}
{virtualize && data.length > 0 ? (
<FixedSizeList
width="100%"
height={itemCount * ITEM_SIZE}
itemCount={data.length}
itemSize={ITEM_SIZE}

View file

@ -1,18 +1,20 @@
import { ReactNode, useMemo, useState } from 'react';
import { Loading, Icon, Text, SearchField } from 'react-basics';
import classNames from 'classnames';
import useApi from 'components/hooks/useApi';
import { percentFilter } from 'lib/filters';
import useDateRange from 'components/hooks/useDateRange';
import useNavigation from 'components/hooks/useNavigation';
import ErrorMessage from 'components/common/ErrorMessage';
import LinkButton from 'components/common/LinkButton';
import ListTable, { ListTableProps } from './ListTable';
import { DEFAULT_ANIMATION_DURATION } from 'lib/constants';
import { percentFilter } from 'lib/filters';
import {
useDateRange,
useNavigation,
useWebsiteMetrics,
useMessages,
useLocale,
useFormat,
} from 'components/hooks';
import Icons from 'components/icons';
import useMessages from 'components/hooks/useMessages';
import useLocale from 'components/hooks/useLocale';
import useFormat from 'components//hooks/useFormat';
import ListTable, { ListTableProps } from './ListTable';
import styles from './MetricsTable.module.css';
export interface MetricsTableProps extends ListTableProps {
@ -43,55 +45,36 @@ export function MetricsTable({
}: MetricsTableProps) {
const [search, setSearch] = useState('');
const { formatValue } = useFormat();
const [{ startDate, endDate, modified }] = useDateRange(websiteId);
const [{ startDate, endDate }] = useDateRange(websiteId);
const {
makeUrl,
renderUrl,
query: { url, referrer, title, os, browser, device, country, region, city },
} = useNavigation();
const { formatMessage, labels } = useMessages();
const { get, useQuery } = useApi();
const { dir } = useLocale();
const { data, isLoading, isFetched, error } = useQuery({
queryKey: [
'websites:metrics',
{
websiteId,
type,
modified,
url,
referrer,
os,
title,
browser,
device,
country,
region,
city,
},
],
queryFn: async () => {
const filters = { url, title, referrer, os, browser, device, country, region, city };
filters[type] = undefined;
const data = await get(`/websites/${websiteId}/metrics`, {
type,
startAt: +startDate,
endAt: +endDate,
limit,
...filters,
});
onDataLoad?.(data);
return data;
const { data, isLoading, isFetched, error } = useWebsiteMetrics(
websiteId,
{
type,
startAt: +startDate,
endAt: +endDate,
url,
referrer,
os,
title,
browser,
device,
country,
region,
city,
},
retryDelay: delay || DEFAULT_ANIMATION_DURATION,
});
{ retryDelay: delay || DEFAULT_ANIMATION_DURATION, onDataLoad },
);
const filteredData = useMemo(() => {
if (data) {
let items: any[] = data;
let items = data as any[];
if (dataFilter) {
if (Array.isArray(dataFilter)) {
@ -142,7 +125,7 @@ export function MetricsTable({
{!data && isLoading && !isFetched && <Loading icon="dots" />}
<div className={styles.footer}>
{data && !error && limit && (
<LinkButton href={makeUrl({ view: type })} variant="quiet">
<LinkButton href={renderUrl({ view: type })} variant="quiet">
<Text>{formatMessage(labels.more)}</Text>
<Icon size="sm" rotate={dir === 'rtl' ? 180 : 0}>
<Icons.ArrowRight />

View file

@ -1,7 +1,6 @@
import MetricsTable, { MetricsTableProps } from './MetricsTable';
import FilterLink from 'components/common/FilterLink';
import useMessages from 'components/hooks/useMessages';
import useFormat from 'components/hooks/useFormat';
import { useMessages, useFormat } from 'components/hooks';
export function OSTable(props: MetricsTableProps) {
const { formatMessage, labels } = useMessages();

View file

@ -1,8 +1,8 @@
import FilterLink from 'components/common/FilterLink';
import FilterButtons from 'components/common/FilterButtons';
import MetricsTable, { MetricsTableProps } from './MetricsTable';
import useMessages from 'components/hooks/useMessages';
import useNavigation from 'components/hooks/useNavigation';
import { useMessages } from 'components/hooks';
import { useNavigation } from 'components/hooks';
import { emptyFilter } from 'lib/filters';
export interface PagesTableProps extends MetricsTableProps {
@ -12,13 +12,13 @@ export interface PagesTableProps extends MetricsTableProps {
export function PagesTable({ allowFilter, domainName, ...props }: PagesTableProps) {
const {
router,
makeUrl,
renderUrl,
query: { view = 'url' },
} = useNavigation();
const { formatMessage, labels } = useMessages();
const handleSelect = (key: any) => {
router.push(makeUrl({ view: key }), { scroll: true });
router.push(renderUrl({ view: key }), { scroll: true });
};
const buttons = [

View file

@ -4,7 +4,7 @@ import FilterButtons from 'components/common/FilterButtons';
import { emptyFilter, paramFilter } from 'lib/filters';
import { FILTER_RAW, FILTER_COMBINED } from 'lib/constants';
import MetricsTable, { MetricsTableProps } from './MetricsTable';
import useMessages from 'components/hooks/useMessages';
import { useMessages } from 'components/hooks';
import styles from './QueryParametersTable.module.css';
const filters = {

View file

@ -1,6 +1,6 @@
import MetricsTable, { MetricsTableProps } from './MetricsTable';
import FilterLink from 'components/common/FilterLink';
import useMessages from 'components/hooks/useMessages';
import { useMessages } from 'components/hooks';
export function ReferrersTable(props: MetricsTableProps) {
const { formatMessage, labels } = useMessages();

View file

@ -1,10 +1,10 @@
import FilterLink from 'components/common/FilterLink';
import { emptyFilter } from 'lib/filters';
import useLocale from 'components/hooks/useLocale';
import useMessages from 'components/hooks/useMessages';
import useCountryNames from 'components/hooks/useCountryNames';
import { useLocale } from 'components/hooks';
import { useMessages } from 'components/hooks';
import { useCountryNames } from 'components/hooks';
import MetricsTable, { MetricsTableProps } from './MetricsTable';
import regions from 'public/iso-3166-2.json';
import regions from '../../../public/iso-3166-2.json';
export function RegionsTable(props: MetricsTableProps) {
const { locale } = useLocale();

View file

@ -1,5 +1,5 @@
import MetricsTable, { MetricsTableProps } from './MetricsTable';
import useMessages from 'components/hooks/useMessages';
import { useMessages } from 'components/hooks';
export function ScreenTable(props: MetricsTableProps) {
const { formatMessage, labels } = useMessages();

View file

@ -4,10 +4,10 @@ import classNames from 'classnames';
import { colord } from 'colord';
import HoverTooltip from 'components/common/HoverTooltip';
import { ISO_COUNTRIES, MAP_FILE } from 'lib/constants';
import useTheme from 'components/hooks/useTheme';
import useCountryNames from 'components/hooks/useCountryNames';
import useLocale from 'components/hooks/useLocale';
import useMessages from 'components/hooks/useMessages';
import { useTheme } from 'components/hooks';
import { useCountryNames } from 'components/hooks';
import { useLocale } from 'components/hooks';
import { useMessages } from 'components/hooks';
import { formatLongNumber } from 'lib/format';
import { percentFilter } from 'lib/filters';
import styles from './WorldMap.module.css';