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,29 @@
import { useState } from 'react';
import { Button, LoadingButton, Form, FormButtons } from 'react-basics';
import useMessages from 'components/hooks/useMessages';
export function ConfirmDeleteForm({ name, onConfirm, onClose }) {
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 loading={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,15 @@
import classNames from 'classnames';
import styles from './Empty.module.css';
import useMessages from 'components/hooks/useMessages';
export function Empty({ message, className }) {
const { formatMessage, messages } = useMessages();
return (
<div className={classNames(styles.container, className)}>
{message || formatMessage(messages.noDataAvailable)}
</div>
);
}
export default Empty;

View file

@ -0,0 +1,11 @@
.container {
color: var(--base500);
font-size: var(--font-size-md);
position: relative;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
width: 100%;
height: 100%;
}

View file

@ -0,0 +1,16 @@
import { Icon, Text, Flexbox } from 'react-basics';
import Logo from 'assets/logo.svg';
export function EmptyPlaceholder({ message, children }) {
return (
<Flexbox direction="column" alignItems="center" justifyContent="center" gap={60} height={600}>
<Icon size="xl">
<Logo />
</Icon>
<Text size="lg">{message}</Text>
<div>{children}</div>
</Flexbox>
);
}
export default EmptyPlaceholder;

View file

@ -0,0 +1,32 @@
/* eslint-disable no-console */
import { ErrorBoundary as Boundary } from 'react-error-boundary';
import { Button } from 'react-basics';
import useMessages from 'components/hooks/useMessages';
import styles from './ErrorBoundry.module.css';
const logError = (error, info) => {
console.error(error, info.componentStack);
};
export function ErrorBoundary({ children }) {
const { formatMessage, messages } = useMessages();
const fallbackRender = ({ error, resetErrorBoundary }) => {
return (
<div className={styles.error} role="alert">
<h1>{formatMessage(messages.error)}</h1>
<h3>{error.message}</h3>
<pre>{error.stack}</pre>
<Button onClick={resetErrorBoundary}>OK</Button>
</div>
);
};
return (
<Boundary fallbackRender={fallbackRender} onError={logError}>
{children}
</Boundary>
);
}
export default ErrorBoundary;

View file

@ -0,0 +1,19 @@
.error {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
margin: auto;
z-index: var(--z-index-overlay);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 600px;
gap: 20px;
}
.error button {
align-self: center;
}

View file

@ -0,0 +1,18 @@
import { Icon, Icons, Text } from 'react-basics';
import styles from './ErrorMessage.module.css';
import useMessages from 'components/hooks/useMessages';
export function ErrorMessage() {
const { formatMessage, messages } = useMessages();
return (
<div className={styles.error}>
<Icon className={styles.icon} size="large">
<Icons.Alert />
</Icon>
<Text>{formatMessage(messages.error)}</Text>
</div>
);
}
export default ErrorMessage;

View file

@ -0,0 +1,15 @@
.error {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
margin: auto;
display: flex;
background-color: var(--base50);
padding: 10px;
z-index: 1;
}
.icon {
margin-right: 10px;
}

View file

@ -0,0 +1,22 @@
import styles from './Favicon.module.css';
function getHostName(url) {
const match = url.match(/^(?:https?:\/\/)?(?:[^@\n]+@)?(?:www\.)?([^:/\n?=]+)/im);
return match && match.length > 1 ? match[1] : null;
}
export function Favicon({ domain, ...props }) {
const hostName = domain ? getHostName(domain) : null;
return hostName ? (
<img
className={styles.favicon}
src={`https://icons.duckduckgo.com/ip3/${hostName}.ico`}
height="16"
alt=""
{...props}
/>
) : null;
}
export default Favicon;

View file

@ -0,0 +1,3 @@
.favicon {
margin-inline-end: 8px;
}

View file

@ -0,0 +1,13 @@
import { ButtonGroup, Button, Flexbox } from 'react-basics';
export function FilterButtons({ items, selectedKey, onSelect }) {
return (
<Flexbox justifyContent="center">
<ButtonGroup items={items} selectedKey={selectedKey} onSelect={onSelect}>
{({ key, label }) => <Button key={key}>{label}</Button>}
</ButtonGroup>
</Flexbox>
);
}
export default FilterButtons;

View file

@ -0,0 +1,40 @@
import { Icon, Icons } from 'react-basics';
import classNames from 'classnames';
import Link from 'next/link';
import { safeDecodeURI } from 'next-basics';
import usePageQuery from 'components/hooks/usePageQuery';
import useMessages from 'components/hooks/useMessages';
import styles from './FilterLink.module.css';
export function FilterLink({ id, value, label, externalUrl, children, className }) {
const { formatMessage, labels } = useMessages();
const { resolveUrl, query } = usePageQuery();
const active = query[id] !== undefined;
const selected = query[id] === value;
return (
<div
className={classNames(styles.row, className, {
[styles.inactive]: active && !selected,
[styles.active]: active && selected,
})}
>
{children}
{!value && `(${label || formatMessage(labels.unknown)})`}
{value && (
<Link href={resolveUrl({ [id]: value })} className={styles.label} replace>
{safeDecodeURI(label || value)}
</Link>
)}
{externalUrl && (
<a className={styles.link} href={externalUrl} target="_blank" rel="noreferrer noopener">
<Icon className={styles.icon}>
<Icons.External />
</Icon>
</a>
)}
</div>
);
}
export default FilterLink;

View file

@ -0,0 +1,37 @@
.row {
display: flex;
align-items: center;
gap: 10px;
}
.row.inactive {
color: var(--base500);
}
.row.inactive img {
opacity: 0.35;
}
.row.active {
color: var(--base900);
font-weight: 600;
}
.row .link {
display: none;
margin-inline-start: 20px;
}
.row .label {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.row:hover .link {
display: block;
}
.icon {
cursor: pointer;
}

View file

@ -0,0 +1,60 @@
import { Button, Icon } from 'react-basics';
import { useState } from 'react';
import MobileMenu from './MobileMenu';
import Icons from 'components/icons';
import useMessages from 'components/hooks/useMessages';
import useConfig from 'components/hooks/useConfig';
export function HamburgerButton() {
const { formatMessage, labels } = useMessages();
const [active, setActive] = useState(false);
const { cloudMode } = useConfig();
const menuItems = [
{
label: formatMessage(labels.dashboard),
url: '/dashboard',
},
!cloudMode && {
label: formatMessage(labels.settings),
url: '/settings',
children: [
{
label: formatMessage(labels.websites),
url: '/settings/websites',
},
{
label: formatMessage(labels.teams),
url: '/settings/teams',
},
{
label: formatMessage(labels.users),
url: '/settings/users',
},
{
label: formatMessage(labels.profile),
url: '/settings/profile',
},
],
},
cloudMode && {
label: formatMessage(labels.profile),
url: '/settings/profile',
},
!cloudMode && { label: formatMessage(labels.logout), url: '/logout' },
].filter(n => n);
const handleClick = () => setActive(state => !state);
const handleClose = () => setActive(false);
return (
<>
<Button variant="quiet" onClick={handleClick}>
<Icon>{active ? <Icons.Close /> : <Icons.Menu />}</Icon>
</Button>
{active && <MobileMenu items={menuItems} onClose={handleClose} />}
</>
);
}
export default HamburgerButton;

View file

@ -0,0 +1,9 @@
.button {
display: none;
}
@media only screen and (max-width: 768px) {
.button {
display: flex;
}
}

View file

@ -0,0 +1,27 @@
import { useEffect, useState } from 'react';
import { Tooltip } from 'react-basics';
import styles from './HoverTooltip.module.css';
export function HoverTooltip({ children }) {
const [position, setPosition] = useState({ x: -1000, y: -1000 });
useEffect(() => {
const handler = e => {
setPosition({ x: e.clientX, y: e.clientY });
};
document.addEventListener('mousemove', handler);
return () => {
document.removeEventListener('mousemove', handler);
};
}, []);
return (
<Tooltip className={styles.tooltip} style={{ left: position.x, top: position.y }}>
{children}
</Tooltip>
);
}
export default HoverTooltip;

View file

@ -0,0 +1,6 @@
.tooltip {
position: fixed;
pointer-events: none;
z-index: var(--z-index-popup);
transform: translate(-50%, calc(-100% - 5px));
}

View file

@ -0,0 +1,14 @@
import Link from 'next/link';
import { Icon, Icons, Text } from 'react-basics';
import styles from './LinkButton.module.css';
export function LinkButton({ href, icon, children }) {
return (
<Link className={styles.button} href={href}>
<Icon>{icon || <Icons.ArrowRight />}</Icon>
<Text>{children}</Text>
</Link>
);
}
export default LinkButton;

View file

@ -0,0 +1,28 @@
.button {
display: flex;
align-items: center;
align-self: flex-start;
white-space: nowrap;
gap: var(--size200);
font-family: inherit;
color: var(--base900);
background: var(--base100);
border: 1px solid transparent;
border-radius: var(--border-radius);
min-height: var(--base-height);
padding: 0 var(--size600);
position: relative;
cursor: pointer;
}
.button:hover {
background: var(--base200);
}
.button:active {
background: var(--base300);
}
.button:visited {
color: var(--base900);
}

View file

@ -0,0 +1,38 @@
import classNames from 'classnames';
import { useRouter } from 'next/router';
import Link from 'next/link';
import styles from './MobileMenu.module.css';
export function MobileMenu({ items = [], onClose }) {
const { pathname } = useRouter();
const Items = ({ items, className }) => (
<div className={classNames(styles.items, className)}>
{items.map(({ label, url, children }) => {
const selected = pathname.startsWith(url);
return (
<>
<Link
key={url}
href={url}
className={classNames(styles.item, { [styles.selected]: selected })}
onClick={onClose}
>
{label}
</Link>
{children && <Items items={children} className={styles.submenu} />}
</>
);
})}
</div>
);
return (
<div className={classNames(styles.menu)}>
<Items items={items} />
</div>
);
}
export default MobileMenu;

View file

@ -0,0 +1,39 @@
.menu {
position: fixed;
top: 60px;
left: 0;
right: 0;
bottom: 0;
margin: auto;
display: flex;
flex-direction: column;
background-color: var(--base50);
z-index: var(--z-index-popup);
overflow: auto;
}
.items {
display: flex;
flex-direction: column;
}
.item {
font-size: var(--font-size-lg);
font-weight: 700;
line-height: 80px;
padding: 0 40px;
}
a.item {
color: var(--base600);
}
a.item.selected,
.submenu a.item.selected {
color: var(--base900);
}
.submenu a.item {
color: var(--base600);
margin-left: 40px;
}

View file

@ -0,0 +1,45 @@
import styles from './Pager.module.css';
import { Button, Flexbox, Icon, Icons } from 'react-basics';
import useMessages from 'components/hooks/useMessages';
export function Pager({ page, pageSize, count, onPageChange }) {
const { formatMessage, labels } = useMessages();
const maxPage = Math.ceil(count / pageSize);
const lastPage = page === maxPage;
const firstPage = page === 1;
if (count === 0) {
return null;
}
const handlePageChange = value => {
const nextPage = page + value;
if (nextPage > 0 && nextPage <= maxPage) {
onPageChange(nextPage);
}
};
if (maxPage === 1) {
return null;
}
return (
<Flexbox justifyContent="center" className={styles.container}>
<Button onClick={() => handlePageChange(-1)} disabled={firstPage}>
<Icon rotate={90}>
<Icons.ChevronDown />
</Icon>
</Button>
<Flexbox alignItems="center" className={styles.text}>
{formatMessage(labels.pageOf, { current: page, total: maxPage })}
</Flexbox>
<Button onClick={() => handlePageChange(1)} disabled={lastPage}>
<Icon rotate={270}>
<Icons.ChevronDown />
</Icon>
</Button>
</Flexbox>
);
}
export default Pager;

View file

@ -0,0 +1,7 @@
.container {
margin-top: 20px;
}
.text {
margin: 0 16px;
}

View file

@ -0,0 +1,100 @@
import Empty from 'components/common/Empty';
import useMessages from 'components/hooks/useMessages';
import { useState } from 'react';
import {
SearchField,
Table,
TableBody,
TableCell,
TableColumn,
TableHeader,
TableRow,
} from 'react-basics';
import styles from './SettingsTable.module.css';
import Pager from 'components/common/Pager';
export function SettingsTable({
columns = [],
data,
children,
cellRender,
showSearch,
showPaging,
onFilterChange,
onPageChange,
onPageSizeChange,
filterValue,
}) {
const { formatMessage, messages } = useMessages();
const [filter, setFilter] = useState(filterValue);
const { data: value, page, count, pageSize } = data;
const handleFilterChange = value => {
setFilter(value);
onFilterChange(value);
};
return (
<>
{showSearch && !!value.length && (
<SearchField
onChange={handleFilterChange}
delay={1000}
value={filter}
autoFocus={true}
placeholder="Search"
style={{ maxWidth: '300px', marginBottom: '10px' }}
/>
)}
{value.length === 0 && filterValue && (
<Empty message={formatMessage(messages.noResultsFound)} />
)}
{value.length > 0 && (
<Table columns={columns} rows={value}>
<TableHeader className={styles.header}>
{(column, index) => {
return (
<TableColumn key={index} className={styles.cell} style={columns[index].style}>
{column.label}
</TableColumn>
);
}}
</TableHeader>
<TableBody className={styles.body}>
{(row, keys, rowIndex) => {
row.action = children(row, keys, rowIndex);
return (
<TableRow key={rowIndex} data={row} keys={keys} className={styles.row}>
{(data, key, colIndex) => {
return (
<TableCell
key={colIndex}
className={styles.cell}
style={columns[colIndex].style}
>
<label className={styles.label}>{columns[colIndex].label}</label>
{cellRender ? cellRender(row, data, key, colIndex) : data[key]}
</TableCell>
);
}}
</TableRow>
);
}}
</TableBody>
{showPaging && (
<Pager
page={page}
pageSize={pageSize}
count={count}
onPageChange={onPageChange}
onPageSizeChange={onPageSizeChange}
/>
)}
</Table>
)}
</>
);
}
export default SettingsTable;

View file

@ -0,0 +1,44 @@
.cell {
align-items: center;
}
.row .cell:last-child {
gap: 10px;
justify-content: flex-end;
}
.label {
display: none;
font-weight: 700;
}
@media screen and (max-width: 992px) {
.header .cell {
display: none;
}
.label {
display: block;
min-width: 100px;
}
.row .cell {
padding-left: 0;
flex-basis: 100%;
}
}
@media screen and (max-width: 1200px) {
.row {
flex-wrap: wrap;
}
.header .cell:last-child {
display: none;
}
.row .cell:last-child {
padding-left: 0;
flex-basis: 100%;
}
}

View file

@ -0,0 +1,62 @@
import { useEffect, useCallback, useState } from 'react';
import { Button, Row, Column } from 'react-basics';
import { setItem } from 'next-basics';
import useStore, { checkVersion } from 'store/version';
import { REPO_URL, VERSION_CHECK } from 'lib/constants';
import styles from './UpdateNotice.module.css';
import useMessages from 'components/hooks/useMessages';
import { useRouter } from 'next/router';
export function UpdateNotice({ user, config }) {
const { formatMessage, labels, messages } = useMessages();
const { latest, checked, hasUpdate, releaseUrl } = useStore();
const { pathname } = useRouter();
const [dismissed, setDismissed] = useState(checked);
const allowUpdate =
user?.isAdmin &&
!config?.updatesDisabled &&
!config?.cloudMode &&
!pathname.includes('/share/') &&
!dismissed;
const updateCheck = useCallback(() => {
setItem(VERSION_CHECK, { version: latest, time: Date.now() });
}, [latest]);
function handleViewClick() {
updateCheck();
setDismissed(true);
open(releaseUrl || REPO_URL, '_blank');
}
function handleDismissClick() {
updateCheck();
setDismissed(true);
}
useEffect(() => {
if (allowUpdate) {
checkVersion();
}
}, [allowUpdate]);
if (!allowUpdate || !hasUpdate) {
return null;
}
return (
<Row className={styles.notice}>
<Column variant="two" className={styles.message}>
{formatMessage(messages.newVersionAvailable, { version: `v${latest}` })}
</Column>
<Column className={styles.buttons}>
<Button variant="primary" onClick={handleViewClick}>
{formatMessage(labels.viewDetails)}
</Button>
<Button onClick={handleDismissClick}>{formatMessage(labels.dismiss)}</Button>
</Column>
</Row>
);
}
export default UpdateNotice;

View file

@ -0,0 +1,34 @@
.notice {
position: absolute;
max-width: 800px;
gap: 20px;
margin: 20px auto;
justify-self: center;
background: var(--base50);
padding: 20px;
border: 1px solid var(--base300);
border-radius: var(--border-radius);
z-index: var(--z-index-popup);
}
.message {
display: flex;
justify-content: center;
align-items: center;
color: var(--font-color100);
font-weight: 700;
}
.buttons {
display: flex;
flex-direction: row;
justify-content: flex-end;
gap: 10px;
flex: 0;
}
@media only screen and (max-width: 992px) {
.message {
height: 80px;
}
}

View file

@ -0,0 +1,85 @@
import { useState, useMemo } from 'react';
import { useRouter } from 'next/router';
import { ComposableMap, Geographies, Geography, ZoomableGroup } from 'react-simple-maps';
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 { formatLongNumber } from 'lib/format';
import { percentFilter } from 'lib/filters';
import styles from './WorldMap.module.css';
export function WorldMap({ data, className }) {
const { basePath } = useRouter();
const [tooltip, setTooltipPopup] = useState();
const { theme, colors } = useTheme();
const { locale } = useLocale();
const countryNames = useCountryNames(locale);
const metrics = useMemo(() => (data ? percentFilter(data) : []), [data]);
function getFillColor(code) {
if (code === 'AQ') return;
const country = metrics?.find(({ x }) => x === code);
if (!country) {
return colors.map.fillColor;
}
return colord(colors.map.baseColor)
[theme === 'light' ? 'lighten' : 'darken'](0.4 * (1.0 - country.z / 100))
.toHex();
}
function getOpacity(code) {
return code === 'AQ' ? 0 : 1;
}
function handleHover(code) {
if (code === 'AQ') return;
const country = metrics?.find(({ x }) => x === code);
setTooltipPopup(`${countryNames[code]}: ${formatLongNumber(country?.y || 0)} visitors`);
}
return (
<div
className={classNames(styles.container, className)}
data-tip=""
data-for="world-map-tooltip"
>
<ComposableMap projection="geoMercator">
<ZoomableGroup zoom={0.8} minZoom={0.7} center={[0, 40]}>
<Geographies geography={`${basePath}${MAP_FILE}`}>
{({ geographies }) => {
return geographies.map(geo => {
const code = ISO_COUNTRIES[geo.id];
return (
<Geography
key={geo.rsmKey}
geography={geo}
fill={getFillColor(code)}
stroke={colors.map.strokeColor}
opacity={getOpacity(code)}
style={{
default: { outline: 'none' },
hover: { outline: 'none', fill: colors.map.hoverColor },
pressed: { outline: 'none' },
}}
onMouseOver={() => handleHover(code)}
onMouseOut={() => setTooltipPopup(null)}
/>
);
});
}}
</Geographies>
</ZoomableGroup>
</ComposableMap>
{tooltip && <HoverTooltip>{tooltip}</HoverTooltip>}
</div>
);
}
export default WorldMap;

View file

@ -0,0 +1,4 @@
.container {
overflow: hidden;
position: relative;
}