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/app/(main)/links/LinkAddButton.tsx | 19 +++
src/app/(main)/links/LinkDeleteButton.tsx | 57 +++++++++
src/app/(main)/links/LinkEditButton.tsx | 16 +++
src/app/(main)/links/LinkEditForm.tsx | 148 +++++++++++++++++++++++
src/app/(main)/links/LinkProvider.tsx | 21 ++++
src/app/(main)/links/LinksDataTable.tsx | 14 +++
src/app/(main)/links/LinksPage.tsx | 26 ++++
src/app/(main)/links/LinksTable.tsx | 51 ++++++++
src/app/(main)/links/[linkId]/LinkControls.tsx | 32 +++++
src/app/(main)/links/[linkId]/LinkHeader.tsx | 19 +++
src/app/(main)/links/[linkId]/LinkMetricsBar.tsx | 70 +++++++++++
src/app/(main)/links/[linkId]/LinkPage.tsx | 34 ++++++
src/app/(main)/links/[linkId]/LinkPanels.tsx | 83 +++++++++++++
src/app/(main)/links/[linkId]/page.tsx | 12 ++
src/app/(main)/links/page.tsx | 10 ++
15 files changed, 612 insertions(+)
create mode 100644 src/app/(main)/links/LinkAddButton.tsx
create mode 100644 src/app/(main)/links/LinkDeleteButton.tsx
create mode 100644 src/app/(main)/links/LinkEditButton.tsx
create mode 100644 src/app/(main)/links/LinkEditForm.tsx
create mode 100644 src/app/(main)/links/LinkProvider.tsx
create mode 100644 src/app/(main)/links/LinksDataTable.tsx
create mode 100644 src/app/(main)/links/LinksPage.tsx
create mode 100644 src/app/(main)/links/LinksTable.tsx
create mode 100644 src/app/(main)/links/[linkId]/LinkControls.tsx
create mode 100644 src/app/(main)/links/[linkId]/LinkHeader.tsx
create mode 100644 src/app/(main)/links/[linkId]/LinkMetricsBar.tsx
create mode 100644 src/app/(main)/links/[linkId]/LinkPage.tsx
create mode 100644 src/app/(main)/links/[linkId]/LinkPanels.tsx
create mode 100644 src/app/(main)/links/[linkId]/page.tsx
create mode 100644 src/app/(main)/links/page.tsx
(limited to 'src/app/(main)/links')
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 (
+ }
+ label={formatMessage(labels.addLink)}
+ variant="primary"
+ width="600px"
+ >
+ {({ close }) => }
+
+ );
+}
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 (
+ }
+ title={formatMessage(labels.confirm)}
+ variant="quiet"
+ width="400px"
+ >
+ {({ close }) => (
+ {name},
+ }}
+ />
+ }
+ isLoading={isPending}
+ error={getErrorMessage(error)}
+ onConfirm={handleConfirm.bind(null, close)}
+ onClose={close}
+ buttonLabel={formatMessage(labels.delete)}
+ buttonVariant="danger"
+ />
+ )}
+
+ );
+}
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 (
+ } title={formatMessage(labels.link)} variant="quiet" width="800px">
+ {({ close }) => {
+ return ;
+ }}
+
+ );
+}
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 ;
+ }
+
+ return (
+
+ );
+}
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(null);
+
+export function LinkProvider({ linkId, children }: { linkId?: string; children: ReactNode }) {
+ const { data: link, isLoading, isFetching } = useLinkQuery(linkId);
+
+ if (isFetching && isLoading) {
+ return ;
+ }
+
+ if (!link) {
+ return null;
+ }
+
+ return {children};
+}
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 (
+
+ {({ data }) => }
+
+ );
+}
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 (
+
+
+
+
+
+
+
+
+
+
+ );
+}
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 (
+
+
+ {({ id, name }: any) => {
+ return {name};
+ }}
+
+
+ {({ slug }: any) => {
+ const url = getSlugUrl(slug);
+ return (
+
+ {url}
+
+ );
+ }}
+
+
+ {({ url }: any) => {
+ return {url};
+ }}
+
+
+ {(row: any) => }
+
+
+ {({ id, name }: any) => {
+ return (
+
+
+
+
+ );
+ }}
+
+
+ );
+}
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 (
+
+
+ {allowFilter ? : }
+ {allowDateFilter && }
+ {allowDownload && }
+ {allowMonthFilter && }
+
+ {allowFilter && }
+
+ );
+}
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 (
+ }>
+
+ } label={formatMessage(labels.view)} />
+
+
+ );
+}
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 (
+
+
+ {metrics?.map(({ label, value, prev, change, formatValue, reverseColors }: any) => {
+ return (
+
+ );
+ })}
+
+
+ );
+}
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 (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
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 (
+
+
+
+ {formatMessage(labels.sources)}
+
+
+ {formatMessage(labels.referrers)}
+ {formatMessage(labels.channels)}
+
+
+
+
+
+
+
+
+
+
+ {formatMessage(labels.environment)}
+
+
+ {formatMessage(labels.browsers)}
+ {formatMessage(labels.os)}
+ {formatMessage(labels.devices)}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {formatMessage(labels.location)}
+
+
+ {formatMessage(labels.countries)}
+ {formatMessage(labels.regions)}
+ {formatMessage(labels.cities)}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
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 ;
+}
+
+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 ;
+}
+
+export const metadata: Metadata = {
+ title: 'Links',
+};
--
cgit v1.2.3