From 396acf3bbbe00a192cb0ea0a9ccf91b1d8d2850b Mon Sep 17 00:00:00 2001 From: Fuwn <50817549+Fuwn@users.noreply.github.com> Date: Sat, 24 Jan 2026 13:09:50 +0000 Subject: Initial commit Created from https://vercel.com/new --- src/components/common/ActionForm.tsx | 15 ++++ src/components/common/AnimatedDiv.tsx | 3 + src/components/common/Avatar.tsx | 21 +++++ src/components/common/ConfirmationForm.tsx | 42 +++++++++ src/components/common/DataGrid.tsx | 107 ++++++++++++++++++++++ src/components/common/DateDisplay.tsx | 28 ++++++ src/components/common/DateDistance.tsx | 19 ++++ src/components/common/Empty.tsx | 24 +++++ src/components/common/EmptyPlaceholder.tsx | 28 ++++++ src/components/common/ErrorBoundary.tsx | 38 ++++++++ src/components/common/ErrorMessage.tsx | 16 ++++ src/components/common/ExternalLink.tsx | 23 +++++ src/components/common/Favicon.tsx | 22 +++++ src/components/common/FilterLink.tsx | 49 +++++++++++ src/components/common/FilterRecord.tsx | 117 +++++++++++++++++++++++++ src/components/common/GridRow.tsx | 32 +++++++ src/components/common/LinkButton.tsx | 41 +++++++++ src/components/common/LoadingPanel.tsx | 71 +++++++++++++++ src/components/common/PageBody.tsx | 42 +++++++++ src/components/common/PageHeader.tsx | 58 ++++++++++++ src/components/common/Pager.tsx | 60 +++++++++++++ src/components/common/Panel.tsx | 64 ++++++++++++++ src/components/common/SectionHeader.tsx | 28 ++++++ src/components/common/SideMenu.tsx | 80 +++++++++++++++++ src/components/common/TypeConfirmationForm.tsx | 55 ++++++++++++ src/components/common/TypeIcon.tsx | 29 ++++++ 26 files changed, 1112 insertions(+) create mode 100644 src/components/common/ActionForm.tsx create mode 100644 src/components/common/AnimatedDiv.tsx create mode 100644 src/components/common/Avatar.tsx create mode 100644 src/components/common/ConfirmationForm.tsx create mode 100644 src/components/common/DataGrid.tsx create mode 100644 src/components/common/DateDisplay.tsx create mode 100644 src/components/common/DateDistance.tsx create mode 100644 src/components/common/Empty.tsx create mode 100644 src/components/common/EmptyPlaceholder.tsx create mode 100644 src/components/common/ErrorBoundary.tsx create mode 100644 src/components/common/ErrorMessage.tsx create mode 100644 src/components/common/ExternalLink.tsx create mode 100644 src/components/common/Favicon.tsx create mode 100644 src/components/common/FilterLink.tsx create mode 100644 src/components/common/FilterRecord.tsx create mode 100644 src/components/common/GridRow.tsx create mode 100644 src/components/common/LinkButton.tsx create mode 100644 src/components/common/LoadingPanel.tsx create mode 100644 src/components/common/PageBody.tsx create mode 100644 src/components/common/PageHeader.tsx create mode 100644 src/components/common/Pager.tsx create mode 100644 src/components/common/Panel.tsx create mode 100644 src/components/common/SectionHeader.tsx create mode 100644 src/components/common/SideMenu.tsx create mode 100644 src/components/common/TypeConfirmationForm.tsx create mode 100644 src/components/common/TypeIcon.tsx (limited to 'src/components/common') 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 ( + + + {label} + {description} + + + {children} + + + ); +} 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 = 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 Avatar; +} 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 ( +
+ {message} + + + + {buttonLabel || formatMessage(labels.ok)} + + +
+ ); +} 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, 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 = () => , + 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 ( + + {allowSearch && ( + + + {renderActions?.()} + + )} + + {data && ( + <> + + {isValidElement(child) + ? cloneElement(child as ReactElement, { displayMode }) + : child} + + {showPager && ( + + + + )} + + )} + + + ); +} 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 ( + + + + + + {isSingleDate ? ( + formatDate(startDate, 'PP', locale) + ) : ( + <> + {formatDate(startDate, 'PP', locale)} + {!isSameDay(startDate, endDate) && ` — ${formatDate(endDate, 'PP', locale)}`} + + )} + + + ); +} 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 ( + + {formatDistanceToNow(date, { addSuffix: true, locale: dateLocale })} + + ); +} 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 ( + + {message || formatMessage(messages.noDataAvailable)} + + ); +} 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 ( + + {icon && ( + + {icon} + + )} + {title && ( + + {title} + + )} + {description && {description}} + {children} + + ); +} 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 ( + +

{formatMessage(messages.error)}

+

{error.message}

+
{error.stack}
+ +
+ ); + }; + + return ( + + {children} + + ); +} 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 ( + + + + + {formatMessage(messages.error)} + + ); +} 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 ( + + + + {children} + + + + + + + ); +} 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 ? : 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 { + 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 ( + setShowLink(true)} + onMouseOut={() => setShowLink(false)} + > + {icon} + {!value && `(${label || formatMessage(labels.unknown)})`} + {value && ( + + + {label || value} + + + )} + {externalUrl && showLink && ( + + + + + + )} + + ); +} 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 ( + + + + + + {isSearch && ( + + )} + {!isSearch && ( + + )} + + + + + + + ); +} 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 ( + + {children} + + ); +} 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 ( + + ); +} 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 = () => , + children, + ...props +}: LoadingPanelProps): ReactNode { + const empty = isEmpty ?? checkEmpty(data); + + // Show loading spinner only if no data exists + if (isLoading || isFetching) { + return ( + + + + ); + } + + // Show error + if (error) { + return ; + } + + // 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 ; + } + + if (isLoading) { + return ; + } + + return ( + + {children} + + ); +} 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 ( + + + {label} + + {icon && ( + + {icon} + + )} + {title && titleHref ? ( + + {title} + + ) : ( + title && {title} + )} + + {description && ( + + {description} + + )} + + + {children} + + + ); +} 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 ( + + {formatMessage(labels.numberOfRecords, { x: count.toLocaleString() })} + + + {formatMessage(labels.pageOf, { + current: page.toLocaleString(), + total: maxPage.toLocaleString(), + })} + + + + + + + + ); +} 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 ( + + {title && {title}} + {allowFullscreen && ( + + + + {formatMessage(labels.maximize)} + + + )} + {children} + + ); +} 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 ( + + + {icon && {icon}} + {title && {title}} + {description && {description}} + + {children} + + ); +} 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 ( + + + {label} + + + ); + }); + }; + + return ( + + {title && ( + + {title} + + )} + + {items?.map(({ label, items }, index) => { + if (label) { + return ( + + {renderItems(items)} + + ); + } + return null; + })} + + + ); +} 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 ( +
+

+ {formatMessage(messages.actionConfirmation, { + confirmation: confirmationValue, + })} +

+ value === confirmationValue }} + > + + + + + + {buttonLabel || formatMessage(labels.ok)} + + +
+ ); +} 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 ( + + { + e.currentTarget.src = `${process.env.basePath || ''}/images/${type}/unknown.png`; + }} + alt={value} + width={type === 'country' ? undefined : 16} + height={type === 'country' ? undefined : 16} + /> + {children} + + ); +} -- cgit v1.2.3