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 ( +
+ {({ setValue }) => { + return ( + <> + + + + + + + + + + + + + + + + + + + + + + {onClose && ( + + )} + {formatMessage(labels.save)} + + + ); + }} +
+ ); +} 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