aboutsummaryrefslogtreecommitdiff
path: root/src/app/(main)/links
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/app/(main)/links
downloadumami-main.tar.xz
umami-main.zip
Initial commitHEADmain
Created from https://vercel.com/new
Diffstat (limited to 'src/app/(main)/links')
-rw-r--r--src/app/(main)/links/LinkAddButton.tsx19
-rw-r--r--src/app/(main)/links/LinkDeleteButton.tsx57
-rw-r--r--src/app/(main)/links/LinkEditButton.tsx16
-rw-r--r--src/app/(main)/links/LinkEditForm.tsx148
-rw-r--r--src/app/(main)/links/LinkProvider.tsx21
-rw-r--r--src/app/(main)/links/LinksDataTable.tsx14
-rw-r--r--src/app/(main)/links/LinksPage.tsx26
-rw-r--r--src/app/(main)/links/LinksTable.tsx51
-rw-r--r--src/app/(main)/links/[linkId]/LinkControls.tsx32
-rw-r--r--src/app/(main)/links/[linkId]/LinkHeader.tsx19
-rw-r--r--src/app/(main)/links/[linkId]/LinkMetricsBar.tsx70
-rw-r--r--src/app/(main)/links/[linkId]/LinkPage.tsx34
-rw-r--r--src/app/(main)/links/[linkId]/LinkPanels.tsx83
-rw-r--r--src/app/(main)/links/[linkId]/page.tsx12
-rw-r--r--src/app/(main)/links/page.tsx10
15 files changed, 612 insertions, 0 deletions
diff --git a/src/app/(main)/links/LinkAddButton.tsx b/src/app/(main)/links/LinkAddButton.tsx
new file mode 100644
index 0000000..4276895
--- /dev/null
+++ b/src/app/(main)/links/LinkAddButton.tsx
@@ -0,0 +1,19 @@
+import { useMessages } from '@/components/hooks';
+import { Plus } from '@/components/icons';
+import { DialogButton } from '@/components/input/DialogButton';
+import { LinkEditForm } from './LinkEditForm';
+
+export function LinkAddButton({ teamId }: { teamId?: string }) {
+ const { formatMessage, labels } = useMessages();
+
+ return (
+ <DialogButton
+ icon={<Plus />}
+ label={formatMessage(labels.addLink)}
+ variant="primary"
+ width="600px"
+ >
+ {({ close }) => <LinkEditForm teamId={teamId} onClose={close} />}
+ </DialogButton>
+ );
+}
diff --git a/src/app/(main)/links/LinkDeleteButton.tsx b/src/app/(main)/links/LinkDeleteButton.tsx
new file mode 100644
index 0000000..78f85f8
--- /dev/null
+++ b/src/app/(main)/links/LinkDeleteButton.tsx
@@ -0,0 +1,57 @@
+import { ConfirmationForm } from '@/components/common/ConfirmationForm';
+import { useDeleteQuery, useMessages } from '@/components/hooks';
+import { Trash } from '@/components/icons';
+import { DialogButton } from '@/components/input/DialogButton';
+import { messages } from '@/components/messages';
+
+export function LinkDeleteButton({
+ linkId,
+ name,
+ onSave,
+}: {
+ linkId: string;
+ websiteId: string;
+ name: string;
+ onSave?: () => void;
+}) {
+ const { formatMessage, labels, getErrorMessage, FormattedMessage } = useMessages();
+ const { mutateAsync, isPending, error, touch } = useDeleteQuery(`/links/${linkId}`);
+
+ const handleConfirm = async (close: () => void) => {
+ await mutateAsync(null, {
+ onSuccess: () => {
+ touch('links');
+ onSave?.();
+ close();
+ },
+ });
+ };
+
+ return (
+ <DialogButton
+ icon={<Trash />}
+ title={formatMessage(labels.confirm)}
+ variant="quiet"
+ 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)/links/LinkEditButton.tsx b/src/app/(main)/links/LinkEditButton.tsx
new file mode 100644
index 0000000..4d85879
--- /dev/null
+++ b/src/app/(main)/links/LinkEditButton.tsx
@@ -0,0 +1,16 @@
+import { useMessages } from '@/components/hooks';
+import { Edit } from '@/components/icons';
+import { DialogButton } from '@/components/input/DialogButton';
+import { LinkEditForm } from './LinkEditForm';
+
+export function LinkEditButton({ linkId }: { linkId: string }) {
+ const { formatMessage, labels } = useMessages();
+
+ return (
+ <DialogButton icon={<Edit />} title={formatMessage(labels.link)} variant="quiet" width="800px">
+ {({ close }) => {
+ return <LinkEditForm linkId={linkId} onClose={close} />;
+ }}
+ </DialogButton>
+ );
+}
diff --git a/src/app/(main)/links/LinkEditForm.tsx b/src/app/(main)/links/LinkEditForm.tsx
new file mode 100644
index 0000000..6c10c7f
--- /dev/null
+++ b/src/app/(main)/links/LinkEditForm.tsx
@@ -0,0 +1,148 @@
+import {
+ Button,
+ Column,
+ Form,
+ FormField,
+ FormSubmitButton,
+ Icon,
+ Label,
+ Loading,
+ Row,
+ TextField,
+} from '@umami/react-zen';
+import { useEffect, useState } from 'react';
+import { useConfig, useLinkQuery, useMessages } from '@/components/hooks';
+import { useUpdateQuery } from '@/components/hooks/queries/useUpdateQuery';
+import { RefreshCw } from '@/components/icons';
+import { LINKS_URL } from '@/lib/constants';
+import { getRandomChars } from '@/lib/generate';
+import { isValidUrl } from '@/lib/url';
+
+const generateId = () => getRandomChars(9);
+
+export function LinkEditForm({
+ linkId,
+ teamId,
+ onSave,
+ onClose,
+}: {
+ linkId?: string;
+ teamId?: string;
+ onSave?: () => void;
+ onClose?: () => void;
+}) {
+ const { formatMessage, labels, messages, getErrorMessage } = useMessages();
+ const { mutateAsync, error, isPending, touch, toast } = useUpdateQuery(
+ linkId ? `/links/${linkId}` : '/links',
+ {
+ id: linkId,
+ teamId,
+ },
+ );
+ const { linksUrl } = useConfig();
+ const hostUrl = linksUrl || LINKS_URL;
+ const { data, isLoading } = useLinkQuery(linkId);
+ const [slug, setSlug] = useState(generateId());
+
+ const handleSubmit = async (data: any) => {
+ await mutateAsync(data, {
+ onSuccess: async () => {
+ toast(formatMessage(messages.saved));
+ touch('links');
+ onSave?.();
+ onClose?.();
+ },
+ });
+ };
+
+ const handleSlug = () => {
+ const slug = generateId();
+
+ setSlug(slug);
+
+ return slug;
+ };
+
+ const checkUrl = (url: string) => {
+ if (!isValidUrl(url)) {
+ return formatMessage(labels.invalidUrl);
+ }
+ return true;
+ };
+
+ useEffect(() => {
+ if (data) {
+ setSlug(data.slug);
+ }
+ }, [data]);
+
+ if (linkId && 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" autoFocus />
+ </FormField>
+
+ <FormField
+ label={formatMessage(labels.destinationUrl)}
+ name="url"
+ rules={{ required: formatMessage(labels.required), validate: checkUrl }}
+ >
+ <TextField placeholder="https://example.com" 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
+ variant="quiet"
+ 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>{formatMessage(labels.save)}</FormSubmitButton>
+ </Row>
+ </>
+ );
+ }}
+ </Form>
+ );
+}
diff --git a/src/app/(main)/links/LinkProvider.tsx b/src/app/(main)/links/LinkProvider.tsx
new file mode 100644
index 0000000..c29e13c
--- /dev/null
+++ b/src/app/(main)/links/LinkProvider.tsx
@@ -0,0 +1,21 @@
+'use client';
+import { Loading } from '@umami/react-zen';
+import { createContext, type ReactNode } from 'react';
+import { useLinkQuery } from '@/components/hooks/queries/useLinkQuery';
+import type { Link } from '@/generated/prisma/client';
+
+export const LinkContext = createContext<Link>(null);
+
+export function LinkProvider({ linkId, children }: { linkId?: string; children: ReactNode }) {
+ const { data: link, isLoading, isFetching } = useLinkQuery(linkId);
+
+ if (isFetching && isLoading) {
+ return <Loading placement="absolute" />;
+ }
+
+ if (!link) {
+ return null;
+ }
+
+ return <LinkContext.Provider value={link}>{children}</LinkContext.Provider>;
+}
diff --git a/src/app/(main)/links/LinksDataTable.tsx b/src/app/(main)/links/LinksDataTable.tsx
new file mode 100644
index 0000000..0b3d660
--- /dev/null
+++ b/src/app/(main)/links/LinksDataTable.tsx
@@ -0,0 +1,14 @@
+import { DataGrid } from '@/components/common/DataGrid';
+import { useLinksQuery, useNavigation } from '@/components/hooks';
+import { LinksTable } from './LinksTable';
+
+export function LinksDataTable() {
+ const { teamId } = useNavigation();
+ const query = useLinksQuery({ teamId });
+
+ return (
+ <DataGrid query={query} allowSearch={true} autoFocus={false} allowPaging={true}>
+ {({ data }) => <LinksTable data={data} />}
+ </DataGrid>
+ );
+}
diff --git a/src/app/(main)/links/LinksPage.tsx b/src/app/(main)/links/LinksPage.tsx
new file mode 100644
index 0000000..a6e4c7c
--- /dev/null
+++ b/src/app/(main)/links/LinksPage.tsx
@@ -0,0 +1,26 @@
+'use client';
+import { Column } from '@umami/react-zen';
+import { LinksDataTable } from '@/app/(main)/links/LinksDataTable';
+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 { LinkAddButton } from './LinkAddButton';
+
+export function LinksPage() {
+ const { formatMessage, labels } = useMessages();
+ const { teamId } = useNavigation();
+
+ return (
+ <PageBody>
+ <Column gap="6" margin="2">
+ <PageHeader title={formatMessage(labels.links)}>
+ <LinkAddButton teamId={teamId} />
+ </PageHeader>
+ <Panel>
+ <LinksDataTable />
+ </Panel>
+ </Column>
+ </PageBody>
+ );
+}
diff --git a/src/app/(main)/links/LinksTable.tsx b/src/app/(main)/links/LinksTable.tsx
new file mode 100644
index 0000000..a3b4a86
--- /dev/null
+++ b/src/app/(main)/links/LinksTable.tsx
@@ -0,0 +1,51 @@
+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 { LinkDeleteButton } from './LinkDeleteButton';
+import { LinkEditButton } from './LinkEditButton';
+
+export function LinksTable(props: DataTableProps) {
+ const { formatMessage, labels } = useMessages();
+ const { websiteId, renderUrl } = useNavigation();
+ const { getSlugUrl } = useSlug('link');
+
+ return (
+ <DataTable {...props}>
+ <DataColumn id="name" label={formatMessage(labels.name)}>
+ {({ id, name }: any) => {
+ return <Link href={renderUrl(`/links/${id}`)}>{name}</Link>;
+ }}
+ </DataColumn>
+ <DataColumn id="slug" label={formatMessage(labels.link)}>
+ {({ slug }: any) => {
+ const url = getSlugUrl(slug);
+ return (
+ <ExternalLink href={url} prefetch={false}>
+ {url}
+ </ExternalLink>
+ );
+ }}
+ </DataColumn>
+ <DataColumn id="url" label={formatMessage(labels.destinationUrl)}>
+ {({ url }: any) => {
+ return <ExternalLink href={url}>{url}</ExternalLink>;
+ }}
+ </DataColumn>
+ <DataColumn id="created" label={formatMessage(labels.created)} width="200px">
+ {(row: any) => <DateDistance date={new Date(row.createdAt)} />}
+ </DataColumn>
+ <DataColumn id="action" align="end" width="100px">
+ {({ id, name }: any) => {
+ return (
+ <Row>
+ <LinkEditButton linkId={id} />
+ <LinkDeleteButton linkId={id} websiteId={websiteId} name={name} />
+ </Row>
+ );
+ }}
+ </DataColumn>
+ </DataTable>
+ );
+}
diff --git a/src/app/(main)/links/[linkId]/LinkControls.tsx b/src/app/(main)/links/[linkId]/LinkControls.tsx
new file mode 100644
index 0000000..1d1147a
--- /dev/null
+++ b/src/app/(main)/links/[linkId]/LinkControls.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 LinkControls({
+ linkId: websiteId,
+ allowFilter = true,
+ allowDateFilter = true,
+ allowMonthFilter,
+ allowDownload = false,
+}: {
+ linkId: 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)/links/[linkId]/LinkHeader.tsx b/src/app/(main)/links/[linkId]/LinkHeader.tsx
new file mode 100644
index 0000000..a84a626
--- /dev/null
+++ b/src/app/(main)/links/[linkId]/LinkHeader.tsx
@@ -0,0 +1,19 @@
+import { IconLabel } from '@umami/react-zen';
+import { LinkButton } from '@/components/common/LinkButton';
+import { PageHeader } from '@/components/common/PageHeader';
+import { useLink, useMessages, useSlug } from '@/components/hooks';
+import { ExternalLink, Link } from '@/components/icons';
+
+export function LinkHeader() {
+ const { formatMessage, labels } = useMessages();
+ const { getSlugUrl } = useSlug('link');
+ const link = useLink();
+
+ return (
+ <PageHeader title={link.name} description={link.url} icon={<Link />}>
+ <LinkButton href={getSlugUrl(link.slug)} target="_blank" prefetch={false} asAnchor>
+ <IconLabel icon={<ExternalLink />} label={formatMessage(labels.view)} />
+ </LinkButton>
+ </PageHeader>
+ );
+}
diff --git a/src/app/(main)/links/[linkId]/LinkMetricsBar.tsx b/src/app/(main)/links/[linkId]/LinkMetricsBar.tsx
new file mode 100644
index 0000000..1fe8c45
--- /dev/null
+++ b/src/app/(main)/links/[linkId]/LinkMetricsBar.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 LinkMetricsBar({
+ linkId,
+}: {
+ linkId: string;
+ showChange?: boolean;
+ compareMode?: boolean;
+}) {
+ const { isAllTime } = useDateRange();
+ const { formatMessage, labels } = useMessages();
+ const { data, isLoading, isFetching, error } = useWebsiteStatsQuery(linkId);
+
+ 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)/links/[linkId]/LinkPage.tsx b/src/app/(main)/links/[linkId]/LinkPage.tsx
new file mode 100644
index 0000000..ddacf08
--- /dev/null
+++ b/src/app/(main)/links/[linkId]/LinkPage.tsx
@@ -0,0 +1,34 @@
+'use client';
+import { Column, Grid } from '@umami/react-zen';
+import { LinkControls } from '@/app/(main)/links/[linkId]/LinkControls';
+import { LinkHeader } from '@/app/(main)/links/[linkId]/LinkHeader';
+import { LinkMetricsBar } from '@/app/(main)/links/[linkId]/LinkMetricsBar';
+import { LinkPanels } from '@/app/(main)/links/[linkId]/LinkPanels';
+import { LinkProvider } from '@/app/(main)/links/LinkProvider';
+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 LinkPage({ linkId }: { linkId: string }) {
+ return (
+ <LinkProvider linkId={linkId}>
+ <Grid width="100%" height="100%">
+ <Column margin="2">
+ <PageBody gap>
+ <LinkHeader />
+ <LinkControls linkId={linkId} />
+ <LinkMetricsBar linkId={linkId} showChange={true} />
+ <Panel>
+ <WebsiteChart websiteId={linkId} />
+ </Panel>
+ <LinkPanels linkId={linkId} />
+ </PageBody>
+ <ExpandedViewModal websiteId={linkId} excludedIds={excludedIds} />
+ </Column>
+ </Grid>
+ </LinkProvider>
+ );
+}
diff --git a/src/app/(main)/links/[linkId]/LinkPanels.tsx b/src/app/(main)/links/[linkId]/LinkPanels.tsx
new file mode 100644
index 0000000..f33525e
--- /dev/null
+++ b/src/app/(main)/links/[linkId]/LinkPanels.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 LinkPanels({ linkId }: { linkId: string }) {
+ const { formatMessage, labels } = useMessages();
+ const tableProps = {
+ websiteId: linkId,
+ 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={linkId} />
+ </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)/links/[linkId]/page.tsx b/src/app/(main)/links/[linkId]/page.tsx
new file mode 100644
index 0000000..4317ada
--- /dev/null
+++ b/src/app/(main)/links/[linkId]/page.tsx
@@ -0,0 +1,12 @@
+import type { Metadata } from 'next';
+import { LinkPage } from './LinkPage';
+
+export default async function ({ params }: { params: Promise<{ linkId: string }> }) {
+ const { linkId } = await params;
+
+ return <LinkPage linkId={linkId} />;
+}
+
+export const metadata: Metadata = {
+ title: 'Link',
+};
diff --git a/src/app/(main)/links/page.tsx b/src/app/(main)/links/page.tsx
new file mode 100644
index 0000000..24c9c18
--- /dev/null
+++ b/src/app/(main)/links/page.tsx
@@ -0,0 +1,10 @@
+import type { Metadata } from 'next';
+import { LinksPage } from './LinksPage';
+
+export default function () {
+ return <LinksPage />;
+}
+
+export const metadata: Metadata = {
+ title: 'Links',
+};