diff options
| author | Fuwn <[email protected]> | 2026-01-24 13:09:50 +0000 |
|---|---|---|
| committer | Fuwn <[email protected]> | 2026-01-24 13:09:50 +0000 |
| commit | 396acf3bbbe00a192cb0ea0a9ccf91b1d8d2850b (patch) | |
| tree | b9df4ca6a70db45cfffbae6fdd7252e20fb8e93c /src/app/(main)/pixels | |
| download | umami-main.tar.xz umami-main.zip | |
Created from https://vercel.com/new
Diffstat (limited to 'src/app/(main)/pixels')
| -rw-r--r-- | src/app/(main)/pixels/PixelAddButton.tsx | 19 | ||||
| -rw-r--r-- | src/app/(main)/pixels/PixelDeleteButton.tsx | 57 | ||||
| -rw-r--r-- | src/app/(main)/pixels/PixelEditButton.tsx | 21 | ||||
| -rw-r--r-- | src/app/(main)/pixels/PixelEditForm.tsx | 129 | ||||
| -rw-r--r-- | src/app/(main)/pixels/PixelProvider.tsx | 21 | ||||
| -rw-r--r-- | src/app/(main)/pixels/PixelsDataTable.tsx | 14 | ||||
| -rw-r--r-- | src/app/(main)/pixels/PixelsPage.tsx | 26 | ||||
| -rw-r--r-- | src/app/(main)/pixels/PixelsTable.tsx | 48 | ||||
| -rw-r--r-- | src/app/(main)/pixels/[pixelId]/PixelControls.tsx | 32 | ||||
| -rw-r--r-- | src/app/(main)/pixels/[pixelId]/PixelHeader.tsx | 19 | ||||
| -rw-r--r-- | src/app/(main)/pixels/[pixelId]/PixelMetricsBar.tsx | 70 | ||||
| -rw-r--r-- | src/app/(main)/pixels/[pixelId]/PixelPage.tsx | 34 | ||||
| -rw-r--r-- | src/app/(main)/pixels/[pixelId]/PixelPanels.tsx | 83 | ||||
| -rw-r--r-- | src/app/(main)/pixels/[pixelId]/page.tsx | 12 | ||||
| -rw-r--r-- | src/app/(main)/pixels/page.tsx | 10 |
15 files changed, 595 insertions, 0 deletions
diff --git a/src/app/(main)/pixels/PixelAddButton.tsx b/src/app/(main)/pixels/PixelAddButton.tsx new file mode 100644 index 0000000..1573b9e --- /dev/null +++ b/src/app/(main)/pixels/PixelAddButton.tsx @@ -0,0 +1,19 @@ +import { useMessages } from '@/components/hooks'; +import { Plus } from '@/components/icons'; +import { DialogButton } from '@/components/input/DialogButton'; +import { PixelEditForm } from './PixelEditForm'; + +export function PixelAddButton({ teamId }: { teamId?: string }) { + const { formatMessage, labels } = useMessages(); + + return ( + <DialogButton + icon={<Plus />} + label={formatMessage(labels.addPixel)} + variant="primary" + width="600px" + > + {({ close }) => <PixelEditForm teamId={teamId} onClose={close} />} + </DialogButton> + ); +} diff --git a/src/app/(main)/pixels/PixelDeleteButton.tsx b/src/app/(main)/pixels/PixelDeleteButton.tsx new file mode 100644 index 0000000..436dba5 --- /dev/null +++ b/src/app/(main)/pixels/PixelDeleteButton.tsx @@ -0,0 +1,57 @@ +import { ConfirmationForm } from '@/components/common/ConfirmationForm'; +import { useDeleteQuery, useMessages, useModified } from '@/components/hooks'; +import { Trash } from '@/components/icons'; +import { DialogButton } from '@/components/input/DialogButton'; +import { messages } from '@/components/messages'; + +export function PixelDeleteButton({ + pixelId, + name, + onSave, +}: { + pixelId: string; + name: string; + onSave?: () => void; +}) { + const { formatMessage, labels, getErrorMessage, FormattedMessage } = useMessages(); + const { mutateAsync, isPending, error } = useDeleteQuery(`/pixels/${pixelId}`); + const { touch } = useModified(); + + const handleConfirm = async (close: () => void) => { + await mutateAsync(null, { + onSuccess: () => { + touch('pixels'); + onSave?.(); + close(); + }, + }); + }; + + return ( + <DialogButton + icon={<Trash />} + variant="quiet" + title={formatMessage(labels.confirm)} + width="400px" + > + {({ close }) => ( + <ConfirmationForm + message={ + <FormattedMessage + {...messages.confirmRemove} + values={{ + target: <b>{name}</b>, + }} + /> + } + isLoading={isPending} + error={getErrorMessage(error)} + onConfirm={handleConfirm.bind(null, close)} + onClose={close} + buttonLabel={formatMessage(labels.delete)} + buttonVariant="danger" + /> + )} + </DialogButton> + ); +} diff --git a/src/app/(main)/pixels/PixelEditButton.tsx b/src/app/(main)/pixels/PixelEditButton.tsx new file mode 100644 index 0000000..3c5924d --- /dev/null +++ b/src/app/(main)/pixels/PixelEditButton.tsx @@ -0,0 +1,21 @@ +import { useMessages } from '@/components/hooks'; +import { Edit } from '@/components/icons'; +import { DialogButton } from '@/components/input/DialogButton'; +import { PixelEditForm } from './PixelEditForm'; + +export function PixelEditButton({ pixelId }: { pixelId: string }) { + const { formatMessage, labels } = useMessages(); + + return ( + <DialogButton + icon={<Edit />} + title={formatMessage(labels.addPixel)} + variant="quiet" + width="600px" + > + {({ close }) => { + return <PixelEditForm pixelId={pixelId} onClose={close} />; + }} + </DialogButton> + ); +} diff --git a/src/app/(main)/pixels/PixelEditForm.tsx b/src/app/(main)/pixels/PixelEditForm.tsx new file mode 100644 index 0000000..aedd3a3 --- /dev/null +++ b/src/app/(main)/pixels/PixelEditForm.tsx @@ -0,0 +1,129 @@ +import { + Button, + Column, + Form, + FormField, + FormSubmitButton, + Icon, + Label, + Loading, + Row, + TextField, +} from '@umami/react-zen'; +import { useEffect, useState } from 'react'; +import { useConfig, useMessages, usePixelQuery } from '@/components/hooks'; +import { useUpdateQuery } from '@/components/hooks/queries/useUpdateQuery'; +import { RefreshCw } from '@/components/icons'; +import { PIXELS_URL } from '@/lib/constants'; +import { getRandomChars } from '@/lib/generate'; + +const generateId = () => getRandomChars(9); + +export function PixelEditForm({ + pixelId, + teamId, + onSave, + onClose, +}: { + pixelId?: string; + teamId?: string; + onSave?: () => void; + onClose?: () => void; +}) { + const { formatMessage, labels, messages, getErrorMessage } = useMessages(); + const { mutateAsync, error, isPending, touch, toast } = useUpdateQuery( + pixelId ? `/pixels/${pixelId}` : '/pixels', + { + id: pixelId, + teamId, + }, + ); + const { pixelsUrl } = useConfig(); + const hostUrl = pixelsUrl || PIXELS_URL; + const { data, isLoading } = usePixelQuery(pixelId); + const [slug, setSlug] = useState(generateId()); + + const handleSubmit = async (data: any) => { + await mutateAsync(data, { + onSuccess: async () => { + toast(formatMessage(messages.saved)); + touch('pixels'); + onSave?.(); + onClose?.(); + }, + }); + }; + + const handleSlug = () => { + const slug = generateId(); + + setSlug(slug); + + return slug; + }; + + useEffect(() => { + if (data) { + setSlug(data.slug); + } + }, [data]); + + if (pixelId && isLoading) { + return <Loading placement="absolute" />; + } + + return ( + <Form onSubmit={handleSubmit} error={getErrorMessage(error)} defaultValues={{ slug, ...data }}> + {({ setValue }) => { + return ( + <> + <FormField + label={formatMessage(labels.name)} + name="name" + rules={{ required: formatMessage(labels.required) }} + > + <TextField autoComplete="off" /> + </FormField> + + <FormField + name="slug" + rules={{ + required: formatMessage(labels.required), + }} + style={{ display: 'none' }} + > + <input type="hidden" /> + </FormField> + + <Column> + <Label>{formatMessage(labels.link)}</Label> + <Row alignItems="center" gap> + <TextField + value={`${hostUrl}/${slug}`} + autoComplete="off" + isReadOnly + allowCopy + style={{ width: '100%' }} + /> + <Button onPress={() => setValue('slug', handleSlug(), { shouldDirty: true })}> + <Icon> + <RefreshCw /> + </Icon> + </Button> + </Row> + </Column> + + <Row justifyContent="flex-end" paddingTop="3" gap="3"> + {onClose && ( + <Button isDisabled={isPending} onPress={onClose}> + {formatMessage(labels.cancel)} + </Button> + )} + <FormSubmitButton isDisabled={false}>{formatMessage(labels.save)}</FormSubmitButton> + </Row> + </> + ); + }} + </Form> + ); +} diff --git a/src/app/(main)/pixels/PixelProvider.tsx b/src/app/(main)/pixels/PixelProvider.tsx new file mode 100644 index 0000000..9e929d8 --- /dev/null +++ b/src/app/(main)/pixels/PixelProvider.tsx @@ -0,0 +1,21 @@ +'use client'; +import { Loading } from '@umami/react-zen'; +import { createContext, type ReactNode } from 'react'; +import { usePixelQuery } from '@/components/hooks/queries/usePixelQuery'; +import type { Pixel } from '@/generated/prisma/client'; + +export const PixelContext = createContext<Pixel>(null); + +export function PixelProvider({ pixelId, children }: { pixelId?: string; children: ReactNode }) { + const { data: pixel, isLoading, isFetching } = usePixelQuery(pixelId); + + if (isFetching && isLoading) { + return <Loading placement="absolute" />; + } + + if (!pixel) { + return null; + } + + return <PixelContext.Provider value={pixel}>{children}</PixelContext.Provider>; +} diff --git a/src/app/(main)/pixels/PixelsDataTable.tsx b/src/app/(main)/pixels/PixelsDataTable.tsx new file mode 100644 index 0000000..51b8c5a --- /dev/null +++ b/src/app/(main)/pixels/PixelsDataTable.tsx @@ -0,0 +1,14 @@ +import { DataGrid } from '@/components/common/DataGrid'; +import { useNavigation, usePixelsQuery } from '@/components/hooks'; +import { PixelsTable } from './PixelsTable'; + +export function PixelsDataTable() { + const { teamId } = useNavigation(); + const query = usePixelsQuery({ teamId }); + + return ( + <DataGrid query={query} allowSearch={true} autoFocus={false} allowPaging={true}> + {({ data }) => <PixelsTable data={data} />} + </DataGrid> + ); +} diff --git a/src/app/(main)/pixels/PixelsPage.tsx b/src/app/(main)/pixels/PixelsPage.tsx new file mode 100644 index 0000000..4f6acef --- /dev/null +++ b/src/app/(main)/pixels/PixelsPage.tsx @@ -0,0 +1,26 @@ +'use client'; +import { Column } from '@umami/react-zen'; +import { PageBody } from '@/components/common/PageBody'; +import { PageHeader } from '@/components/common/PageHeader'; +import { Panel } from '@/components/common/Panel'; +import { useMessages, useNavigation } from '@/components/hooks'; +import { PixelAddButton } from './PixelAddButton'; +import { PixelsDataTable } from './PixelsDataTable'; + +export function PixelsPage() { + const { formatMessage, labels } = useMessages(); + const { teamId } = useNavigation(); + + return ( + <PageBody> + <Column gap="6" margin="2"> + <PageHeader title={formatMessage(labels.pixels)}> + <PixelAddButton teamId={teamId} /> + </PageHeader> + <Panel> + <PixelsDataTable /> + </Panel> + </Column> + </PageBody> + ); +} diff --git a/src/app/(main)/pixels/PixelsTable.tsx b/src/app/(main)/pixels/PixelsTable.tsx new file mode 100644 index 0000000..48a8458 --- /dev/null +++ b/src/app/(main)/pixels/PixelsTable.tsx @@ -0,0 +1,48 @@ +import { DataColumn, DataTable, type DataTableProps, Row } from '@umami/react-zen'; +import Link from 'next/link'; +import { DateDistance } from '@/components/common/DateDistance'; +import { ExternalLink } from '@/components/common/ExternalLink'; +import { useMessages, useNavigation, useSlug } from '@/components/hooks'; +import { PixelDeleteButton } from './PixelDeleteButton'; +import { PixelEditButton } from './PixelEditButton'; + +export function PixelsTable(props: DataTableProps) { + const { formatMessage, labels } = useMessages(); + const { renderUrl } = useNavigation(); + const { getSlugUrl } = useSlug('pixel'); + + return ( + <DataTable {...props}> + <DataColumn id="name" label={formatMessage(labels.name)}> + {({ id, name }: any) => { + return <Link href={renderUrl(`/pixels/${id}`)}>{name}</Link>; + }} + </DataColumn> + <DataColumn id="url" label="URL"> + {({ slug }: any) => { + const url = getSlugUrl(slug); + return ( + <ExternalLink href={url} prefetch={false}> + {url} + </ExternalLink> + ); + }} + </DataColumn> + <DataColumn id="created" label={formatMessage(labels.created)}> + {(row: any) => <DateDistance date={new Date(row.createdAt)} />} + </DataColumn> + <DataColumn id="action" align="end" width="100px"> + {(row: any) => { + const { id, name } = row; + + return ( + <Row> + <PixelEditButton pixelId={id} /> + <PixelDeleteButton pixelId={id} name={name} /> + </Row> + ); + }} + </DataColumn> + </DataTable> + ); +} diff --git a/src/app/(main)/pixels/[pixelId]/PixelControls.tsx b/src/app/(main)/pixels/[pixelId]/PixelControls.tsx new file mode 100644 index 0000000..55dcd57 --- /dev/null +++ b/src/app/(main)/pixels/[pixelId]/PixelControls.tsx @@ -0,0 +1,32 @@ +import { Column, Row } from '@umami/react-zen'; +import { ExportButton } from '@/components/input/ExportButton'; +import { FilterBar } from '@/components/input/FilterBar'; +import { MonthFilter } from '@/components/input/MonthFilter'; +import { WebsiteDateFilter } from '@/components/input/WebsiteDateFilter'; +import { WebsiteFilterButton } from '@/components/input/WebsiteFilterButton'; + +export function PixelControls({ + pixelId: websiteId, + allowFilter = true, + allowDateFilter = true, + allowMonthFilter, + allowDownload = false, +}: { + pixelId: string; + allowFilter?: boolean; + allowDateFilter?: boolean; + allowMonthFilter?: boolean; + allowDownload?: boolean; +}) { + return ( + <Column gap> + <Row alignItems="center" justifyContent="space-between" gap="3"> + {allowFilter ? <WebsiteFilterButton websiteId={websiteId} /> : <div />} + {allowDateFilter && <WebsiteDateFilter websiteId={websiteId} showAllTime={false} />} + {allowDownload && <ExportButton websiteId={websiteId} />} + {allowMonthFilter && <MonthFilter />} + </Row> + {allowFilter && <FilterBar websiteId={websiteId} />} + </Column> + ); +} diff --git a/src/app/(main)/pixels/[pixelId]/PixelHeader.tsx b/src/app/(main)/pixels/[pixelId]/PixelHeader.tsx new file mode 100644 index 0000000..c771687 --- /dev/null +++ b/src/app/(main)/pixels/[pixelId]/PixelHeader.tsx @@ -0,0 +1,19 @@ +import { IconLabel } from '@umami/react-zen'; +import { LinkButton } from '@/components/common/LinkButton'; +import { PageHeader } from '@/components/common/PageHeader'; +import { useMessages, usePixel, useSlug } from '@/components/hooks'; +import { ExternalLink, Grid2x2 } from '@/components/icons'; + +export function PixelHeader() { + const { formatMessage, labels } = useMessages(); + const { getSlugUrl } = useSlug('pixel'); + const pixel = usePixel(); + + return ( + <PageHeader title={pixel.name} icon={<Grid2x2 />}> + <LinkButton href={getSlugUrl(pixel.slug)} target="_blank" prefetch={false} asAnchor> + <IconLabel icon={<ExternalLink />} label={formatMessage(labels.view)} /> + </LinkButton> + </PageHeader> + ); +} diff --git a/src/app/(main)/pixels/[pixelId]/PixelMetricsBar.tsx b/src/app/(main)/pixels/[pixelId]/PixelMetricsBar.tsx new file mode 100644 index 0000000..c9dcd35 --- /dev/null +++ b/src/app/(main)/pixels/[pixelId]/PixelMetricsBar.tsx @@ -0,0 +1,70 @@ +import { LoadingPanel } from '@/components/common/LoadingPanel'; +import { useDateRange, useMessages } from '@/components/hooks'; +import { useWebsiteStatsQuery } from '@/components/hooks/queries/useWebsiteStatsQuery'; +import { MetricCard } from '@/components/metrics/MetricCard'; +import { MetricsBar } from '@/components/metrics/MetricsBar'; +import { formatLongNumber } from '@/lib/format'; + +export function PixelMetricsBar({ + pixelId, +}: { + pixelId: string; + showChange?: boolean; + compareMode?: boolean; +}) { + const { isAllTime } = useDateRange(); + const { formatMessage, labels } = useMessages(); + const { data, isLoading, isFetching, error } = useWebsiteStatsQuery(pixelId); + + const { pageviews, visitors, visits, comparison } = data || {}; + + const metrics = data + ? [ + { + value: visitors, + label: formatMessage(labels.visitors), + change: visitors - comparison.visitors, + formatValue: formatLongNumber, + }, + { + value: visits, + label: formatMessage(labels.visits), + change: visits - comparison.visits, + formatValue: formatLongNumber, + }, + { + value: pageviews, + label: formatMessage(labels.views), + change: pageviews - comparison.pageviews, + formatValue: formatLongNumber, + }, + ] + : null; + + return ( + <LoadingPanel + data={metrics} + isLoading={isLoading} + isFetching={isFetching} + error={error} + minHeight="136px" + > + <MetricsBar> + {metrics?.map(({ label, value, prev, change, formatValue, reverseColors }: any) => { + return ( + <MetricCard + key={label} + value={value} + previousValue={prev} + label={label} + change={change} + formatValue={formatValue} + reverseColors={reverseColors} + showChange={!isAllTime} + /> + ); + })} + </MetricsBar> + </LoadingPanel> + ); +} diff --git a/src/app/(main)/pixels/[pixelId]/PixelPage.tsx b/src/app/(main)/pixels/[pixelId]/PixelPage.tsx new file mode 100644 index 0000000..7a4ae9d --- /dev/null +++ b/src/app/(main)/pixels/[pixelId]/PixelPage.tsx @@ -0,0 +1,34 @@ +'use client'; +import { Column, Grid } from '@umami/react-zen'; +import { PixelControls } from '@/app/(main)/pixels/[pixelId]/PixelControls'; +import { PixelHeader } from '@/app/(main)/pixels/[pixelId]/PixelHeader'; +import { PixelMetricsBar } from '@/app/(main)/pixels/[pixelId]/PixelMetricsBar'; +import { PixelPanels } from '@/app/(main)/pixels/[pixelId]/PixelPanels'; +import { PixelProvider } from '@/app/(main)/pixels/PixelProvider'; +import { ExpandedViewModal } from '@/app/(main)/websites/[websiteId]/ExpandedViewModal'; +import { WebsiteChart } from '@/app/(main)/websites/[websiteId]/WebsiteChart'; +import { PageBody } from '@/components/common/PageBody'; +import { Panel } from '@/components/common/Panel'; + +const excludedIds = ['path', 'entry', 'exit', 'title', 'language', 'screen', 'event']; + +export function PixelPage({ pixelId }: { pixelId: string }) { + return ( + <PixelProvider pixelId={pixelId}> + <Grid width="100%" height="100%"> + <Column margin="2"> + <PageBody gap> + <PixelHeader /> + <PixelControls pixelId={pixelId} /> + <PixelMetricsBar pixelId={pixelId} showChange={true} /> + <Panel> + <WebsiteChart websiteId={pixelId} /> + </Panel> + <PixelPanels pixelId={pixelId} /> + </PageBody> + <ExpandedViewModal websiteId={pixelId} excludedIds={excludedIds} /> + </Column> + </Grid> + </PixelProvider> + ); +} diff --git a/src/app/(main)/pixels/[pixelId]/PixelPanels.tsx b/src/app/(main)/pixels/[pixelId]/PixelPanels.tsx new file mode 100644 index 0000000..9cc24c9 --- /dev/null +++ b/src/app/(main)/pixels/[pixelId]/PixelPanels.tsx @@ -0,0 +1,83 @@ +import { Grid, Heading, Tab, TabList, TabPanel, Tabs } from '@umami/react-zen'; +import { GridRow } from '@/components/common/GridRow'; +import { Panel } from '@/components/common/Panel'; +import { useMessages } from '@/components/hooks'; +import { MetricsTable } from '@/components/metrics/MetricsTable'; +import { WorldMap } from '@/components/metrics/WorldMap'; + +export function PixelPanels({ pixelId }: { pixelId: string }) { + const { formatMessage, labels } = useMessages(); + const tableProps = { + websiteId: pixelId, + limit: 10, + allowDownload: false, + showMore: true, + metric: formatMessage(labels.visitors), + }; + const rowProps = { minHeight: 570 }; + + return ( + <Grid gap="3"> + <GridRow layout="two" {...rowProps}> + <Panel> + <Heading size="2">{formatMessage(labels.sources)}</Heading> + <Tabs> + <TabList> + <Tab id="referrer">{formatMessage(labels.referrers)}</Tab> + <Tab id="channel">{formatMessage(labels.channels)}</Tab> + </TabList> + <TabPanel id="referrer"> + <MetricsTable type="referrer" title={formatMessage(labels.domain)} {...tableProps} /> + </TabPanel> + <TabPanel id="channel"> + <MetricsTable type="channel" title={formatMessage(labels.type)} {...tableProps} /> + </TabPanel> + </Tabs> + </Panel> + <Panel> + <Heading size="2">{formatMessage(labels.environment)}</Heading> + <Tabs> + <TabList> + <Tab id="browser">{formatMessage(labels.browsers)}</Tab> + <Tab id="os">{formatMessage(labels.os)}</Tab> + <Tab id="device">{formatMessage(labels.devices)}</Tab> + </TabList> + <TabPanel id="browser"> + <MetricsTable type="browser" title={formatMessage(labels.browser)} {...tableProps} /> + </TabPanel> + <TabPanel id="os"> + <MetricsTable type="os" title={formatMessage(labels.os)} {...tableProps} /> + </TabPanel> + <TabPanel id="device"> + <MetricsTable type="device" title={formatMessage(labels.device)} {...tableProps} /> + </TabPanel> + </Tabs> + </Panel> + </GridRow> + <GridRow layout="two" {...rowProps}> + <Panel padding="0"> + <WorldMap websiteId={pixelId} /> + </Panel> + <Panel> + <Heading size="2">{formatMessage(labels.location)}</Heading> + <Tabs> + <TabList> + <Tab id="country">{formatMessage(labels.countries)}</Tab> + <Tab id="region">{formatMessage(labels.regions)}</Tab> + <Tab id="city">{formatMessage(labels.cities)}</Tab> + </TabList> + <TabPanel id="country"> + <MetricsTable type="country" title={formatMessage(labels.country)} {...tableProps} /> + </TabPanel> + <TabPanel id="region"> + <MetricsTable type="region" title={formatMessage(labels.region)} {...tableProps} /> + </TabPanel> + <TabPanel id="city"> + <MetricsTable type="city" title={formatMessage(labels.city)} {...tableProps} /> + </TabPanel> + </Tabs> + </Panel> + </GridRow> + </Grid> + ); +} diff --git a/src/app/(main)/pixels/[pixelId]/page.tsx b/src/app/(main)/pixels/[pixelId]/page.tsx new file mode 100644 index 0000000..d1db92f --- /dev/null +++ b/src/app/(main)/pixels/[pixelId]/page.tsx @@ -0,0 +1,12 @@ +import type { Metadata } from 'next'; +import { PixelPage } from './PixelPage'; + +export default async function ({ params }: { params: { pixelId: string } }) { + const { pixelId } = await params; + + return <PixelPage pixelId={pixelId} />; +} + +export const metadata: Metadata = { + title: 'Pixel', +}; diff --git a/src/app/(main)/pixels/page.tsx b/src/app/(main)/pixels/page.tsx new file mode 100644 index 0000000..cc240cd --- /dev/null +++ b/src/app/(main)/pixels/page.tsx @@ -0,0 +1,10 @@ +import type { Metadata } from 'next'; +import { PixelsPage } from './PixelsPage'; + +export default function () { + return <PixelsPage />; +} + +export const metadata: Metadata = { + title: 'Pixels', +}; |