diff options
Diffstat (limited to 'src/components/common')
26 files changed, 1112 insertions, 0 deletions
diff --git a/src/components/common/ActionForm.tsx b/src/components/common/ActionForm.tsx new file mode 100644 index 0000000..c6f44e8 --- /dev/null +++ b/src/components/common/ActionForm.tsx @@ -0,0 +1,15 @@ +import { Column, Row, Text } from '@umami/react-zen'; + +export function ActionForm({ label, description, children }) { + return ( + <Row alignItems="center" justifyContent="space-between" gap> + <Column gap="2"> + <Text weight="bold">{label}</Text> + <Text color="muted">{description}</Text> + </Column> + <Row alignItems="center" gap> + {children} + </Row> + </Row> + ); +} diff --git a/src/components/common/AnimatedDiv.tsx b/src/components/common/AnimatedDiv.tsx new file mode 100644 index 0000000..f994897 --- /dev/null +++ b/src/components/common/AnimatedDiv.tsx @@ -0,0 +1,3 @@ +import { type AnimatedComponent, animated } from '@react-spring/web'; + +export const AnimatedDiv: AnimatedComponent<any> = animated.div; diff --git a/src/components/common/Avatar.tsx b/src/components/common/Avatar.tsx new file mode 100644 index 0000000..9b198b3 --- /dev/null +++ b/src/components/common/Avatar.tsx @@ -0,0 +1,21 @@ +import { lorelei } from '@dicebear/collection'; +import { createAvatar } from '@dicebear/core'; +import { useMemo } from 'react'; +import { getColor, getPastel } from '@/lib/colors'; + +const lib = lorelei; + +export function Avatar({ seed, size = 128, ...props }: { seed: string; size?: number }) { + const backgroundColor = getPastel(getColor(seed), 4); + + const avatar = useMemo(() => { + return createAvatar(lib, { + ...props, + seed, + size, + backgroundColor: [backgroundColor], + }).toDataUri(); + }, []); + + return <img src={avatar} alt="Avatar" style={{ borderRadius: '100%', width: size }} />; +} diff --git a/src/components/common/ConfirmationForm.tsx b/src/components/common/ConfirmationForm.tsx new file mode 100644 index 0000000..b909ef5 --- /dev/null +++ b/src/components/common/ConfirmationForm.tsx @@ -0,0 +1,42 @@ +import { Box, Button, Form, FormButtons, FormSubmitButton } from '@umami/react-zen'; +import type { ReactNode } from 'react'; +import { useMessages } from '@/components/hooks'; + +export interface ConfirmationFormProps { + message: ReactNode; + buttonLabel?: ReactNode; + buttonVariant?: 'primary' | '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, getErrorMessage } = useMessages(); + + return ( + <Form onSubmit={onConfirm} error={getErrorMessage(error)}> + <Box marginY="4">{message}</Box> + <FormButtons> + <Button onPress={onClose}>{formatMessage(labels.cancel)}</Button> + <FormSubmitButton + data-test="button-confirm" + isLoading={isLoading} + variant={buttonVariant} + isDisabled={false} + > + {buttonLabel || formatMessage(labels.ok)} + </FormSubmitButton> + </FormButtons> + </Form> + ); +} diff --git a/src/components/common/DataGrid.tsx b/src/components/common/DataGrid.tsx new file mode 100644 index 0000000..7e07b8d --- /dev/null +++ b/src/components/common/DataGrid.tsx @@ -0,0 +1,107 @@ +import type { UseQueryResult } from '@tanstack/react-query'; +import { Column, Row, SearchField } from '@umami/react-zen'; +import { + cloneElement, + isValidElement, + type ReactElement, + type ReactNode, + useCallback, + useState, +} from 'react'; +import { Empty } from '@/components/common/Empty'; +import { LoadingPanel } from '@/components/common/LoadingPanel'; +import { Pager } from '@/components/common/Pager'; +import { useMessages, useMobile, useNavigation } from '@/components/hooks'; +import type { PageResult } from '@/lib/types'; + +const DEFAULT_SEARCH_DELAY = 600; + +export interface DataGridProps { + query: UseQueryResult<PageResult<any>, any>; + searchDelay?: number; + allowSearch?: boolean; + allowPaging?: boolean; + autoFocus?: boolean; + renderActions?: () => ReactNode; + renderEmpty?: () => ReactNode; + children: ReactNode | ((data: any) => ReactNode); +} + +export function DataGrid({ + query, + searchDelay = 600, + allowSearch, + allowPaging = true, + autoFocus, + renderActions, + renderEmpty = () => <Empty />, + children, +}: DataGridProps) { + const { formatMessage, labels } = useMessages(); + const { data, error, isLoading, isFetching } = query; + const { router, updateParams, query: queryParams } = useNavigation(); + const [search, setSearch] = useState(queryParams?.search || data?.search || ''); + const showPager = allowPaging && data && data.count > data.pageSize; + const { isMobile } = useMobile(); + const displayMode = isMobile ? 'cards' : undefined; + + const handleSearch = (value: string) => { + if (value !== search) { + setSearch(value); + router.push(updateParams({ search: value, page: 1 })); + } + }; + + const handlePageChange = useCallback( + (page: number) => { + router.push(updateParams({ search, page })); + }, + [search], + ); + + const child = data ? (typeof children === 'function' ? children(data) : children) : null; + + return ( + <Column gap="4" minHeight="300px"> + {allowSearch && ( + <Row alignItems="center" justifyContent="space-between" wrap="wrap" gap> + <SearchField + value={search} + onSearch={handleSearch} + delay={searchDelay || DEFAULT_SEARCH_DELAY} + autoFocus={autoFocus} + placeholder={formatMessage(labels.search)} + /> + {renderActions?.()} + </Row> + )} + <LoadingPanel + data={data} + isLoading={isLoading} + isFetching={isFetching} + error={error} + renderEmpty={renderEmpty} + > + {data && ( + <> + <Column> + {isValidElement(child) + ? cloneElement(child as ReactElement<any>, { displayMode }) + : child} + </Column> + {showPager && ( + <Row marginTop="6"> + <Pager + page={data.page} + pageSize={data.pageSize} + count={data.count} + onPageChange={handlePageChange} + /> + </Row> + )} + </> + )} + </LoadingPanel> + </Column> + ); +} diff --git a/src/components/common/DateDisplay.tsx b/src/components/common/DateDisplay.tsx new file mode 100644 index 0000000..0bece8a --- /dev/null +++ b/src/components/common/DateDisplay.tsx @@ -0,0 +1,28 @@ +import { Icon, Row, Text } from '@umami/react-zen'; +import { differenceInDays, isSameDay } from 'date-fns'; +import { useLocale } from '@/components/hooks'; +import { Calendar } from '@/components/icons'; +import { formatDate } from '@/lib/date'; + +export function DateDisplay({ startDate, endDate }) { + const { locale } = useLocale(); + const isSingleDate = differenceInDays(endDate, startDate) === 0; + + return ( + <Row gap="3" alignItems="center" wrap="nowrap"> + <Icon> + <Calendar /> + </Icon> + <Text wrap="nowrap"> + {isSingleDate ? ( + formatDate(startDate, 'PP', locale) + ) : ( + <> + {formatDate(startDate, 'PP', locale)} + {!isSameDay(startDate, endDate) && ` — ${formatDate(endDate, 'PP', locale)}`} + </> + )} + </Text> + </Row> + ); +} diff --git a/src/components/common/DateDistance.tsx b/src/components/common/DateDistance.tsx new file mode 100644 index 0000000..e8bd278 --- /dev/null +++ b/src/components/common/DateDistance.tsx @@ -0,0 +1,19 @@ +import { Text } from '@umami/react-zen'; +import { formatDistanceToNow } from 'date-fns'; +import { useLocale, useTimezone } from '@/components/hooks'; +import { isInvalidDate } from '@/lib/date'; + +export function DateDistance({ date }: { date: Date }) { + const { formatTimezoneDate } = useTimezone(); + const { dateLocale } = useLocale(); + + if (isInvalidDate(date)) { + return null; + } + + return ( + <Text title={formatTimezoneDate(date?.toISOString(), 'PPPpp')}> + {formatDistanceToNow(date, { addSuffix: true, locale: dateLocale })} + </Text> + ); +} diff --git a/src/components/common/Empty.tsx b/src/components/common/Empty.tsx new file mode 100644 index 0000000..8bd8d82 --- /dev/null +++ b/src/components/common/Empty.tsx @@ -0,0 +1,24 @@ +import { Row } from '@umami/react-zen'; +import { useMessages } from '@/components/hooks'; + +export interface EmptyProps { + message?: string; +} + +export function Empty({ message }: EmptyProps) { + const { formatMessage, messages } = useMessages(); + + return ( + <Row + color="muted" + alignItems="center" + justifyContent="center" + width="100%" + height="100%" + minHeight="70px" + flexGrow={1} + > + {message || formatMessage(messages.noDataAvailable)} + </Row> + ); +} diff --git a/src/components/common/EmptyPlaceholder.tsx b/src/components/common/EmptyPlaceholder.tsx new file mode 100644 index 0000000..64492e0 --- /dev/null +++ b/src/components/common/EmptyPlaceholder.tsx @@ -0,0 +1,28 @@ +import { Column, Icon, Text } from '@umami/react-zen'; +import type { ReactNode } from 'react'; + +export interface EmptyPlaceholderProps { + title?: string; + description?: string; + icon?: ReactNode; + children?: ReactNode; +} + +export function EmptyPlaceholder({ title, description, icon, children }: EmptyPlaceholderProps) { + return ( + <Column alignItems="center" justifyContent="center" gap="5" height="100%" width="100%"> + {icon && ( + <Icon color="10" size="xl"> + {icon} + </Icon> + )} + {title && ( + <Text weight="bold" size="4"> + {title} + </Text> + )} + {description && <Text color="muted">{description}</Text>} + {children} + </Column> + ); +} diff --git a/src/components/common/ErrorBoundary.tsx b/src/components/common/ErrorBoundary.tsx new file mode 100644 index 0000000..4c0c82e --- /dev/null +++ b/src/components/common/ErrorBoundary.tsx @@ -0,0 +1,38 @@ +import { Button, Column } from '@umami/react-zen'; +import type { ErrorInfo, ReactNode } from 'react'; +import { ErrorBoundary as Boundary } from 'react-error-boundary'; +import { useMessages } from '@/components/hooks'; + +const logError = (error: Error, info: ErrorInfo) => { + // eslint-disable-next-line no-console + console.error(error, info.componentStack); +}; + +export function ErrorBoundary({ children }: { children: ReactNode }) { + const { formatMessage, messages } = useMessages(); + + const fallbackRender = ({ error, resetErrorBoundary }) => { + return ( + <Column + role="alert" + gap + width="100%" + height="100%" + position="absolute" + justifyContent="center" + alignItems="center" + > + <h1>{formatMessage(messages.error)}</h1> + <h3>{error.message}</h3> + <pre>{error.stack}</pre> + <Button onClick={resetErrorBoundary}>OK</Button> + </Column> + ); + }; + + return ( + <Boundary fallbackRender={fallbackRender} onError={logError}> + {children} + </Boundary> + ); +} diff --git a/src/components/common/ErrorMessage.tsx b/src/components/common/ErrorMessage.tsx new file mode 100644 index 0000000..3c30151 --- /dev/null +++ b/src/components/common/ErrorMessage.tsx @@ -0,0 +1,16 @@ +import { Icon, Row, Text } from '@umami/react-zen'; +import { useMessages } from '@/components/hooks'; +import { AlertTriangle } from '@/components/icons'; + +export function ErrorMessage() { + const { formatMessage, messages } = useMessages(); + + return ( + <Row alignItems="center" justifyContent="center" gap> + <Icon> + <AlertTriangle /> + </Icon> + <Text>{formatMessage(messages.error)}</Text> + </Row> + ); +} diff --git a/src/components/common/ExternalLink.tsx b/src/components/common/ExternalLink.tsx new file mode 100644 index 0000000..dec0d16 --- /dev/null +++ b/src/components/common/ExternalLink.tsx @@ -0,0 +1,23 @@ +import { Icon, Row, Text } from '@umami/react-zen'; +import Link, { type LinkProps } from 'next/link'; +import type { ReactNode } from 'react'; +import { ExternalLink as LinkIcon } from '@/components/icons'; + +export function ExternalLink({ + href, + children, + ...props +}: LinkProps & { href: string; children: ReactNode }) { + return ( + <Row alignItems="center" overflow="hidden" gap> + <Text title={href} truncate> + <Link {...props} href={href} target="_blank"> + {children} + </Link> + </Text> + <Icon size="sm" strokeColor="muted"> + <LinkIcon /> + </Icon> + </Row> + ); +} diff --git a/src/components/common/Favicon.tsx b/src/components/common/Favicon.tsx new file mode 100644 index 0000000..a6b5e52 --- /dev/null +++ b/src/components/common/Favicon.tsx @@ -0,0 +1,22 @@ +import { useConfig } from '@/components/hooks'; +import { FAVICON_URL, GROUPED_DOMAINS } from '@/lib/constants'; + +function getHostName(url: string) { + const match = url.match(/^(?:https?:\/\/)?(?:[^@\n]+@)?([^:/\n?=]+)/im); + return match && match.length > 1 ? match[1] : null; +} + +export function Favicon({ domain, ...props }) { + const config = useConfig(); + + if (config?.privateMode) { + return null; + } + + const url = config?.faviconUrl || FAVICON_URL; + const hostName = domain ? getHostName(domain) : null; + const domainName = GROUPED_DOMAINS[hostName]?.domain || hostName; + const src = hostName ? url.replace(/\{\{\s*domain\s*}}/, domainName) : null; + + return hostName ? <img src={src} width={16} height={16} alt="" {...props} /> : null; +} diff --git a/src/components/common/FilterLink.tsx b/src/components/common/FilterLink.tsx new file mode 100644 index 0000000..d719a37 --- /dev/null +++ b/src/components/common/FilterLink.tsx @@ -0,0 +1,49 @@ +import { Icon, Row, Text } from '@umami/react-zen'; +import Link from 'next/link'; +import { type HTMLAttributes, type ReactNode, useState } from 'react'; +import { useMessages, useNavigation } from '@/components/hooks'; +import { ExternalLink } from '@/components/icons'; + +export interface FilterLinkProps extends HTMLAttributes<HTMLDivElement> { + type: string; + value: string; + label?: string; + icon?: ReactNode; + externalUrl?: string; +} + +export function FilterLink({ type, value, label, externalUrl, icon }: FilterLinkProps) { + const [showLink, setShowLink] = useState(false); + const { formatMessage, labels } = useMessages(); + const { updateParams, query } = useNavigation(); + const active = query[type] !== undefined; + const selected = query[type] === value; + + return ( + <Row + alignItems="center" + gap + fontWeight={active && selected ? 'bold' : undefined} + color={active && !selected ? 'muted' : undefined} + onMouseOver={() => setShowLink(true)} + onMouseOut={() => setShowLink(false)} + > + {icon} + {!value && `(${label || formatMessage(labels.unknown)})`} + {value && ( + <Text title={label || value} truncate> + <Link href={updateParams({ [type]: `eq.${value}` })} replace> + {label || value} + </Link> + </Text> + )} + {externalUrl && showLink && ( + <a href={externalUrl} target="_blank" rel="noreferrer noopener"> + <Icon color="muted"> + <ExternalLink /> + </Icon> + </a> + )} + </Row> + ); +} diff --git a/src/components/common/FilterRecord.tsx b/src/components/common/FilterRecord.tsx new file mode 100644 index 0000000..0400264 --- /dev/null +++ b/src/components/common/FilterRecord.tsx @@ -0,0 +1,117 @@ +import { Button, Column, Grid, Icon, Label, ListItem, Select, TextField } from '@umami/react-zen'; +import { useState } from 'react'; +import { Empty } from '@/components/common/Empty'; +import { useFilters, useFormat, useWebsiteValuesQuery } from '@/components/hooks'; +import { X } from '@/components/icons'; +import { isSearchOperator } from '@/lib/params'; + +export interface FilterRecordProps { + websiteId: string; + type: string; + startDate: Date; + endDate: Date; + name: string; + operator: string; + value: string; + onSelect?: (name: string, value: any) => void; + onRemove?: (name: string) => void; + onChange?: (name: string, value: string) => void; +} + +export function FilterRecord({ + websiteId, + type, + startDate, + endDate, + name, + operator, + value, + onSelect, + onRemove, + onChange, +}: FilterRecordProps) { + const { fields, operators } = useFilters(); + const [selected, setSelected] = useState(value); + const [search, setSearch] = useState(''); + const { formatValue } = useFormat(); + const { data, isLoading } = useWebsiteValuesQuery({ + websiteId, + type, + search, + startDate, + endDate, + }); + const isSearch = isSearchOperator(operator); + const items = data?.filter(({ value }) => value) || []; + + const handleSearch = (value: string) => { + setSearch(value); + }; + + const handleSelectOperator = (value: any) => { + onSelect?.(name, value); + }; + + const handleSelectValue = (value: string) => { + setSelected(value); + onChange?.(name, value); + }; + + const renderValue = () => { + return formatValue(selected, type); + }; + + return ( + <Column> + <Label>{fields.find(f => f.name === name)?.label}</Label> + <Grid columns="1fr auto" gap> + <Grid columns={{ xs: '1fr', md: '200px 1fr' }} gap> + <Select + items={operators.filter(({ type }) => type === 'string')} + value={operator} + onChange={handleSelectOperator} + > + {({ name, label }: any) => { + return ( + <ListItem key={name} id={name}> + {label} + </ListItem> + ); + }} + </Select> + {isSearch && ( + <TextField value={selected} defaultValue={selected} onChange={handleSelectValue} /> + )} + {!isSearch && ( + <Select + items={items} + value={selected} + onChange={handleSelectValue} + searchValue={search} + renderValue={renderValue} + onSearch={handleSearch} + isLoading={isLoading} + listProps={{ renderEmptyState: () => <Empty /> }} + allowSearch + > + {items?.map(({ value }) => { + return ( + <ListItem key={value} id={value}> + {formatValue(value, type)} + </ListItem> + ); + })} + </Select> + )} + </Grid> + <Column justifyContent="flex-start"> + <Button onPress={() => onRemove?.(name)}> + <Icon> + <X /> + </Icon> + </Button> + </Column> + </Grid> + </Column> + ); +} diff --git a/src/components/common/GridRow.tsx b/src/components/common/GridRow.tsx new file mode 100644 index 0000000..72f1db6 --- /dev/null +++ b/src/components/common/GridRow.tsx @@ -0,0 +1,32 @@ +import { Grid } from '@umami/react-zen'; + +const LAYOUTS = { + one: { columns: '1fr' }, + two: { + columns: { + xs: '1fr', + md: 'repeat(auto-fill, minmax(560px, 1fr))', + }, + }, + three: { + columns: { + xs: '1fr', + md: 'repeat(auto-fill, minmax(360px, 1fr))', + }, + }, + 'one-two': { columns: { xs: '1fr', md: 'repeat(3, 1fr)' } }, + 'two-one': { columns: { xs: '1fr', md: 'repeat(3, 1fr)' } }, +}; + +export function GridRow(props: { + layout?: 'one' | 'two' | 'three' | 'one-two' | 'two-one' | 'compare'; + className?: string; + children?: any; +}) { + const { layout = 'two', children, ...otherProps } = props; + return ( + <Grid gap="3" {...LAYOUTS[layout]} {...otherProps}> + {children} + </Grid> + ); +} diff --git a/src/components/common/LinkButton.tsx b/src/components/common/LinkButton.tsx new file mode 100644 index 0000000..35292ba --- /dev/null +++ b/src/components/common/LinkButton.tsx @@ -0,0 +1,41 @@ +import { Button, type ButtonProps } from '@umami/react-zen'; +import Link from 'next/link'; +import type { ReactNode } from 'react'; +import { useLocale } from '@/components/hooks'; + +export interface LinkButtonProps extends ButtonProps { + href: string; + target?: string; + scroll?: boolean; + variant?: any; + prefetch?: boolean; + asAnchor?: boolean; + children?: ReactNode; +} + +export function LinkButton({ + href, + variant, + scroll = true, + target, + prefetch, + children, + asAnchor, + ...props +}: LinkButtonProps) { + const { dir } = useLocale(); + + return ( + <Button {...props} variant={variant} asChild> + {asAnchor ? ( + <a href={href} target={target}> + {children} + </a> + ) : ( + <Link href={href} dir={dir} scroll={scroll} target={target} prefetch={prefetch}> + {children} + </Link> + )} + </Button> + ); +} diff --git a/src/components/common/LoadingPanel.tsx b/src/components/common/LoadingPanel.tsx new file mode 100644 index 0000000..fb37e14 --- /dev/null +++ b/src/components/common/LoadingPanel.tsx @@ -0,0 +1,71 @@ +import { Column, type ColumnProps, Loading } from '@umami/react-zen'; +import type { ReactNode } from 'react'; +import { Empty } from '@/components/common/Empty'; +import { ErrorMessage } from '@/components/common/ErrorMessage'; + +export interface LoadingPanelProps extends ColumnProps { + data?: any; + error?: unknown; + isEmpty?: boolean; + isLoading?: boolean; + isFetching?: boolean; + loadingIcon?: 'dots' | 'spinner'; + loadingPlacement?: 'center' | 'absolute' | 'inline'; + renderEmpty?: () => ReactNode; + children: ReactNode; +} + +export function LoadingPanel({ + data, + error, + isEmpty, + isLoading, + isFetching, + loadingIcon = 'dots', + loadingPlacement = 'absolute', + renderEmpty = () => <Empty />, + children, + ...props +}: LoadingPanelProps): ReactNode { + const empty = isEmpty ?? checkEmpty(data); + + // Show loading spinner only if no data exists + if (isLoading || isFetching) { + return ( + <Column position="relative" height="100%" width="100%" {...props}> + <Loading icon={loadingIcon} placement={loadingPlacement} /> + </Column> + ); + } + + // Show error + if (error) { + return <ErrorMessage />; + } + + // Show empty state (once loaded) + if (!error && !isLoading && !isFetching && empty) { + return renderEmpty(); + } + + // Show main content when data exists + if (!isLoading && !isFetching && !error && !empty) { + return children; + } + + return null; +} + +function checkEmpty(data: any) { + if (!data) return false; + + if (Array.isArray(data)) { + return data.length <= 0; + } + + if (typeof data === 'object') { + return Object.keys(data).length <= 0; + } + + return !!data; +} diff --git a/src/components/common/PageBody.tsx b/src/components/common/PageBody.tsx new file mode 100644 index 0000000..f07e589 --- /dev/null +++ b/src/components/common/PageBody.tsx @@ -0,0 +1,42 @@ +'use client'; +import { AlertBanner, Column, type ColumnProps, Loading } from '@umami/react-zen'; +import type { ReactNode } from 'react'; +import { useMessages } from '@/components/hooks'; + +const DEFAULT_WIDTH = '1320px'; + +export function PageBody({ + maxWidth = DEFAULT_WIDTH, + error, + isLoading, + children, + ...props +}: { + maxWidth?: string; + error?: unknown; + isLoading?: boolean; + children?: ReactNode; +} & ColumnProps) { + const { formatMessage, messages } = useMessages(); + + if (error) { + return <AlertBanner title={formatMessage(messages.error)} variant="error" />; + } + + if (isLoading) { + return <Loading placement="absolute" />; + } + + return ( + <Column + {...props} + width="100%" + paddingBottom="6" + maxWidth={maxWidth} + paddingX={{ xs: '3', md: '6' }} + style={{ margin: '0 auto' }} + > + {children} + </Column> + ); +} diff --git a/src/components/common/PageHeader.tsx b/src/components/common/PageHeader.tsx new file mode 100644 index 0000000..9216788 --- /dev/null +++ b/src/components/common/PageHeader.tsx @@ -0,0 +1,58 @@ +import { Column, Grid, Heading, Icon, Row, Text } from '@umami/react-zen'; +import type { ReactNode } from 'react'; +import { LinkButton } from './LinkButton'; + +export function PageHeader({ + title, + description, + label, + icon, + showBorder = true, + titleHref, + children, +}: { + title: string; + description?: string; + label?: ReactNode; + icon?: ReactNode; + showBorder?: boolean; + titleHref?: string; + allowEdit?: boolean; + className?: string; + children?: ReactNode; +}) { + return ( + <Grid + columns={{ xs: '1fr', md: '1fr 1fr' }} + paddingY="6" + marginBottom="6" + border={showBorder ? 'bottom' : undefined} + > + <Column gap="2"> + {label} + <Row alignItems="center" gap="3"> + {icon && ( + <Icon size="md" color="muted"> + {icon} + </Icon> + )} + {title && titleHref ? ( + <LinkButton href={titleHref} variant="quiet"> + <Heading size={{ xs: '2', md: '3', lg: '4' }}>{title}</Heading> + </LinkButton> + ) : ( + title && <Heading size={{ xs: '2', md: '3', lg: '4' }}>{title}</Heading> + )} + </Row> + {description && ( + <Text color="muted" truncate style={{ maxWidth: 600 }} title={description}> + {description} + </Text> + )} + </Column> + <Row justifyContent="flex-end" alignItems="center"> + {children} + </Row> + </Grid> + ); +} diff --git a/src/components/common/Pager.tsx b/src/components/common/Pager.tsx new file mode 100644 index 0000000..c65e2f6 --- /dev/null +++ b/src/components/common/Pager.tsx @@ -0,0 +1,60 @@ +import { Button, Icon, Row, Text } from '@umami/react-zen'; +import { useMessages } from '@/components/hooks'; +import { ChevronRight } from '@/components/icons'; + +export interface PagerProps { + page: string | number; + pageSize: string | number; + count: string | number; + onPageChange: (nextPage: number) => void; + className?: string; +} + +export function Pager({ page, pageSize, count, onPageChange }: PagerProps) { + const { formatMessage, labels } = useMessages(); + const maxPage = pageSize && count ? Math.ceil(+count / +pageSize) : 0; + const lastPage = page === maxPage; + const firstPage = page === 1; + + if (count === 0 || !maxPage) { + return null; + } + + const handlePageChange = (value: number) => { + const nextPage = +page + +value; + + if (nextPage > 0 && nextPage <= maxPage) { + onPageChange(nextPage); + } + }; + + if (maxPage === 1) { + return null; + } + + return ( + <Row alignItems="center" justifyContent="space-between" gap="3" flexGrow={1}> + <Text>{formatMessage(labels.numberOfRecords, { x: count.toLocaleString() })}</Text> + <Row alignItems="center" justifyContent="flex-end" gap="3"> + <Text> + {formatMessage(labels.pageOf, { + current: page.toLocaleString(), + total: maxPage.toLocaleString(), + })} + </Text> + <Row gap="1"> + <Button variant="outline" onPress={() => handlePageChange(-1)} isDisabled={firstPage}> + <Icon size="sm" rotate={180}> + <ChevronRight /> + </Icon> + </Button> + <Button variant="outline" onPress={() => handlePageChange(1)} isDisabled={lastPage}> + <Icon size="sm"> + <ChevronRight /> + </Icon> + </Button> + </Row> + </Row> + </Row> + ); +} diff --git a/src/components/common/Panel.tsx b/src/components/common/Panel.tsx new file mode 100644 index 0000000..bb66746 --- /dev/null +++ b/src/components/common/Panel.tsx @@ -0,0 +1,64 @@ +import { + Button, + Column, + type ColumnProps, + Heading, + Icon, + Row, + Tooltip, + TooltipTrigger, +} from '@umami/react-zen'; +import { useState } from 'react'; +import { useMessages } from '@/components/hooks'; +import { Maximize, X } from '@/components/icons'; + +export interface PanelProps extends ColumnProps { + title?: string; + allowFullscreen?: boolean; +} + +const fullscreenStyles = { + position: 'fixed', + width: '100%', + height: '100%', + top: 0, + left: 0, + border: 'none', + zIndex: 9999, +} as any; + +export function Panel({ title, allowFullscreen, style, children, ...props }: PanelProps) { + const { formatMessage, labels } = useMessages(); + const [isFullscreen, setIsFullscreen] = useState(false); + + const handleFullscreen = () => { + setIsFullscreen(!isFullscreen); + }; + + return ( + <Column + paddingY="6" + paddingX={{ xs: '3', md: '6' }} + border + borderRadius="3" + backgroundColor + position="relative" + gap + {...props} + style={{ ...style, ...(isFullscreen ? fullscreenStyles : {}) }} + > + {title && <Heading>{title}</Heading>} + {allowFullscreen && ( + <Row justifyContent="flex-end" alignItems="center"> + <TooltipTrigger delay={0} isDisabled={isFullscreen}> + <Button size="sm" variant="quiet" onPress={handleFullscreen}> + <Icon>{isFullscreen ? <X /> : <Maximize />}</Icon> + </Button> + <Tooltip>{formatMessage(labels.maximize)}</Tooltip> + </TooltipTrigger> + </Row> + )} + {children} + </Column> + ); +} diff --git a/src/components/common/SectionHeader.tsx b/src/components/common/SectionHeader.tsx new file mode 100644 index 0000000..5b911ef --- /dev/null +++ b/src/components/common/SectionHeader.tsx @@ -0,0 +1,28 @@ +import { Heading, Icon, Row, type RowProps, Text } from '@umami/react-zen'; +import type { ReactNode } from 'react'; + +export function SectionHeader({ + title, + description, + icon, + children, + ...props +}: { + title?: string; + description?: string; + icon?: ReactNode; + allowEdit?: boolean; + className?: string; + children?: ReactNode; +} & RowProps) { + return ( + <Row {...props} justifyContent="space-between" alignItems="center" height="60px"> + <Row gap="3" alignItems="center"> + {icon && <Icon size="md">{icon}</Icon>} + {title && <Heading size="3">{title}</Heading>} + {description && <Text color="muted">{description}</Text>} + </Row> + <Row justifyContent="flex-end">{children}</Row> + </Row> + ); +} diff --git a/src/components/common/SideMenu.tsx b/src/components/common/SideMenu.tsx new file mode 100644 index 0000000..92ff798 --- /dev/null +++ b/src/components/common/SideMenu.tsx @@ -0,0 +1,80 @@ +import { + Column, + Heading, + IconLabel, + NavMenu, + NavMenuGroup, + NavMenuItem, + type NavMenuProps, + Row, +} from '@umami/react-zen'; +import Link from 'next/link'; + +interface SideMenuData { + id: string; + label: string; + icon?: any; + path: string; +} + +interface SideMenuItems { + label?: string; + items: SideMenuData[]; +} + +export interface SideMenuProps extends NavMenuProps { + items: SideMenuItems[]; + title?: string; + selectedKey?: string; + allowMinimize?: boolean; +} + +export function SideMenu({ + items = [], + title, + selectedKey, + allowMinimize, + ...props +}: SideMenuProps) { + const renderItems = (items: SideMenuData[]) => { + return items?.map(({ id, label, icon, path }) => { + const isSelected = selectedKey === id; + + return ( + <Link key={id} href={path}> + <NavMenuItem isSelected={isSelected}> + <IconLabel icon={icon}>{label}</IconLabel> + </NavMenuItem> + </Link> + ); + }); + }; + + return ( + <Column gap overflowY="auto" justifyContent="space-between" position="sticky" top="20px"> + {title && ( + <Row padding> + <Heading size="1">{title}</Heading> + </Row> + )} + <NavMenu gap="6" {...props}> + {items?.map(({ label, items }, index) => { + if (label) { + return ( + <NavMenuGroup + title={label} + key={`${label}${index}`} + gap="1" + allowMinimize={allowMinimize} + marginBottom="3" + > + {renderItems(items)} + </NavMenuGroup> + ); + } + return null; + })} + </NavMenu> + </Column> + ); +} diff --git a/src/components/common/TypeConfirmationForm.tsx b/src/components/common/TypeConfirmationForm.tsx new file mode 100644 index 0000000..1121fa7 --- /dev/null +++ b/src/components/common/TypeConfirmationForm.tsx @@ -0,0 +1,55 @@ +import { + Button, + Form, + FormButtons, + FormField, + FormSubmitButton, + TextField, +} from '@umami/react-zen'; +import { useMessages } from '@/components/hooks'; + +export function TypeConfirmationForm({ + confirmationValue, + buttonLabel, + buttonVariant, + isLoading, + error, + onConfirm, + onClose, +}: { + confirmationValue: string; + buttonLabel?: string; + buttonVariant?: 'primary' | 'outline' | 'quiet' | 'danger' | 'zero'; + isLoading?: boolean; + error?: string | Error; + onConfirm?: () => void; + onClose?: () => void; +}) { + const { formatMessage, labels, messages, getErrorMessage } = useMessages(); + if (!confirmationValue) { + return null; + } + + return ( + <Form onSubmit={onConfirm} error={getErrorMessage(error)}> + <p> + {formatMessage(messages.actionConfirmation, { + confirmation: confirmationValue, + })} + </p> + <FormField + label={formatMessage(labels.confirm)} + name="confirm" + rules={{ validate: value => value === confirmationValue }} + > + <TextField autoComplete="off" /> + </FormField> + <FormButtons> + <Button onPress={onClose}>{formatMessage(labels.cancel)}</Button> + <FormSubmitButton isLoading={isLoading} variant={buttonVariant}> + {buttonLabel || formatMessage(labels.ok)} + </FormSubmitButton> + </FormButtons> + </Form> + ); +} diff --git a/src/components/common/TypeIcon.tsx b/src/components/common/TypeIcon.tsx new file mode 100644 index 0000000..8894b3a --- /dev/null +++ b/src/components/common/TypeIcon.tsx @@ -0,0 +1,29 @@ +import { Row } from '@umami/react-zen'; +import type { ReactNode } from 'react'; + +export function TypeIcon({ + type, + value, + children, +}: { + type: 'browser' | 'country' | 'device' | 'os'; + value: string; + children?: ReactNode; +}) { + return ( + <Row gap="3" alignItems="center"> + <img + src={`${process.env.basePath || ''}/images/${type}/${ + value?.replaceAll(' ', '-').toLowerCase() || 'unknown' + }.png`} + onError={e => { + e.currentTarget.src = `${process.env.basePath || ''}/images/${type}/unknown.png`; + }} + alt={value} + width={type === 'country' ? undefined : 16} + height={type === 'country' ? undefined : 16} + /> + {children} + </Row> + ); +} |