aboutsummaryrefslogtreecommitdiff
path: root/src/components/common
diff options
context:
space:
mode:
authorFuwn <[email protected]>2026-01-24 13:09:50 +0000
committerFuwn <[email protected]>2026-01-24 13:09:50 +0000
commit396acf3bbbe00a192cb0ea0a9ccf91b1d8d2850b (patch)
treeb9df4ca6a70db45cfffbae6fdd7252e20fb8e93c /src/components/common
downloadumami-396acf3bbbe00a192cb0ea0a9ccf91b1d8d2850b.tar.xz
umami-396acf3bbbe00a192cb0ea0a9ccf91b1d8d2850b.zip
Initial commitHEADmain
Created from https://vercel.com/new
Diffstat (limited to 'src/components/common')
-rw-r--r--src/components/common/ActionForm.tsx15
-rw-r--r--src/components/common/AnimatedDiv.tsx3
-rw-r--r--src/components/common/Avatar.tsx21
-rw-r--r--src/components/common/ConfirmationForm.tsx42
-rw-r--r--src/components/common/DataGrid.tsx107
-rw-r--r--src/components/common/DateDisplay.tsx28
-rw-r--r--src/components/common/DateDistance.tsx19
-rw-r--r--src/components/common/Empty.tsx24
-rw-r--r--src/components/common/EmptyPlaceholder.tsx28
-rw-r--r--src/components/common/ErrorBoundary.tsx38
-rw-r--r--src/components/common/ErrorMessage.tsx16
-rw-r--r--src/components/common/ExternalLink.tsx23
-rw-r--r--src/components/common/Favicon.tsx22
-rw-r--r--src/components/common/FilterLink.tsx49
-rw-r--r--src/components/common/FilterRecord.tsx117
-rw-r--r--src/components/common/GridRow.tsx32
-rw-r--r--src/components/common/LinkButton.tsx41
-rw-r--r--src/components/common/LoadingPanel.tsx71
-rw-r--r--src/components/common/PageBody.tsx42
-rw-r--r--src/components/common/PageHeader.tsx58
-rw-r--r--src/components/common/Pager.tsx60
-rw-r--r--src/components/common/Panel.tsx64
-rw-r--r--src/components/common/SectionHeader.tsx28
-rw-r--r--src/components/common/SideMenu.tsx80
-rw-r--r--src/components/common/TypeConfirmationForm.tsx55
-rw-r--r--src/components/common/TypeIcon.tsx29
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>
+ );
+}