diff --git a/src/app/(main)/websites/[websiteId]/WebsiteHeader.tsx b/src/app/(main)/websites/[websiteId]/WebsiteHeader.tsx
index 7dd1d771..e79576dd 100644
--- a/src/app/(main)/websites/[websiteId]/WebsiteHeader.tsx
+++ b/src/app/(main)/websites/[websiteId]/WebsiteHeader.tsx
@@ -1,11 +1,9 @@
import { Icon, Row, Text } from '@umami/react-zen';
-import { WebsiteShareForm } from '@/app/(main)/websites/[websiteId]/settings/WebsiteShareForm';
import { Favicon } from '@/components/common/Favicon';
import { LinkButton } from '@/components/common/LinkButton';
import { PageHeader } from '@/components/common/PageHeader';
import { useMessages, useNavigation, useWebsite } from '@/components/hooks';
-import { Edit, Share } from '@/components/icons';
-import { DialogButton } from '@/components/input/DialogButton';
+import { Edit } from '@/components/icons';
import { ActiveUsers } from '@/components/metrics/ActiveUsers';
export function WebsiteHeader({ showActions }: { showActions?: boolean }) {
@@ -29,29 +27,14 @@ export function WebsiteHeader({ showActions }: { showActions?: boolean }) {
{showActions && (
-
-
-
-
-
-
- {formatMessage(labels.edit)}
-
-
+
+
+
+
+ {formatMessage(labels.edit)}
+
)}
);
}
-
-const ShareButton = ({ websiteId, shareId }) => {
- const { formatMessage, labels } = useMessages();
-
- return (
- } label={formatMessage(labels.share)} width="800px">
- {({ close }) => {
- return ;
- }}
-
- );
-};
diff --git a/src/app/(main)/websites/[websiteId]/settings/ShareDeleteButton.tsx b/src/app/(main)/websites/[websiteId]/settings/ShareDeleteButton.tsx
new file mode 100644
index 00000000..35e96df3
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/settings/ShareDeleteButton.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 ShareDeleteButton({
+ shareId,
+ slug,
+ onSave,
+}: {
+ shareId: string;
+ slug: string;
+ onSave?: () => void;
+}) {
+ const { formatMessage, labels, getErrorMessage, FormattedMessage } = useMessages();
+ const { mutateAsync, isPending, error } = useDeleteQuery(`/share/id/${shareId}`);
+ const { touch } = useModified();
+
+ const handleConfirm = async (close: () => void) => {
+ await mutateAsync(null, {
+ onSuccess: () => {
+ touch('shares');
+ onSave?.();
+ close();
+ },
+ });
+ };
+
+ return (
+ }
+ title={formatMessage(labels.confirm)}
+ variant="quiet"
+ width="400px"
+ >
+ {({ close }) => (
+ {slug},
+ }}
+ />
+ }
+ isLoading={isPending}
+ error={getErrorMessage(error)}
+ onConfirm={handleConfirm.bind(null, close)}
+ onClose={close}
+ buttonLabel={formatMessage(labels.delete)}
+ buttonVariant="danger"
+ />
+ )}
+
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/settings/ShareEditButton.tsx b/src/app/(main)/websites/[websiteId]/settings/ShareEditButton.tsx
new file mode 100644
index 00000000..df1c2e64
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/settings/ShareEditButton.tsx
@@ -0,0 +1,16 @@
+import { useMessages } from '@/components/hooks';
+import { Edit } from '@/components/icons';
+import { DialogButton } from '@/components/input/DialogButton';
+import { ShareEditForm } from './ShareEditForm';
+
+export function ShareEditButton({ shareId }: { shareId: string }) {
+ const { formatMessage, labels } = useMessages();
+
+ return (
+ } title={formatMessage(labels.share)} variant="quiet" width="600px">
+ {({ close }) => {
+ return ;
+ }}
+
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/settings/ShareEditForm.tsx b/src/app/(main)/websites/[websiteId]/settings/ShareEditForm.tsx
new file mode 100644
index 00000000..b1d7d50a
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/settings/ShareEditForm.tsx
@@ -0,0 +1,94 @@
+import {
+ Button,
+ Column,
+ Form,
+ FormSubmitButton,
+ Label,
+ Loading,
+ Row,
+ TextField,
+} from '@umami/react-zen';
+import { useEffect, useState } from 'react';
+import { useApi, useConfig, useMessages, useModified } from '@/components/hooks';
+import { useUpdateQuery } from '@/components/hooks/queries/useUpdateQuery';
+
+export function ShareEditForm({
+ shareId,
+ onSave,
+ onClose,
+}: {
+ shareId: string;
+ onSave?: () => void;
+ onClose?: () => void;
+}) {
+ const { formatMessage, labels, messages, getErrorMessage } = useMessages();
+ const { mutateAsync, error, isPending, touch, toast } = useUpdateQuery(`/share/id/${shareId}`);
+ const { cloudMode } = useConfig();
+ const { get } = useApi();
+ const { modified } = useModified('shares');
+ const [share, setShare] = useState(null);
+ const [isLoading, setIsLoading] = useState(true);
+
+ const getUrl = (slug: string) => {
+ if (cloudMode) {
+ return `${process.env.cloudUrl}/share/${slug}`;
+ }
+ return `${window?.location.origin}${process.env.basePath || ''}/share/${slug}`;
+ };
+
+ useEffect(() => {
+ const loadShare = async () => {
+ setIsLoading(true);
+ try {
+ const data = await get(`/share/id/${shareId}`);
+ setShare(data);
+ } finally {
+ setIsLoading(false);
+ }
+ };
+ loadShare();
+ }, [shareId, modified]);
+
+ const handleSubmit = async (data: any) => {
+ await mutateAsync(
+ { slug: data.slug, parameters: share?.parameters || {} },
+ {
+ onSuccess: async () => {
+ toast(formatMessage(messages.saved));
+ touch('shares');
+ onSave?.();
+ onClose?.();
+ },
+ },
+ );
+ };
+
+ if (isLoading) {
+ return ;
+ }
+
+ const url = getUrl(share?.slug || '');
+
+ return (
+
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/settings/SharesTable.tsx b/src/app/(main)/websites/[websiteId]/settings/SharesTable.tsx
new file mode 100644
index 00000000..05e8b357
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/settings/SharesTable.tsx
@@ -0,0 +1,46 @@
+import { DataColumn, DataTable, type DataTableProps, Row } from '@umami/react-zen';
+import { DateDistance } from '@/components/common/DateDistance';
+import { ExternalLink } from '@/components/common/ExternalLink';
+import { useConfig, useMessages } from '@/components/hooks';
+import { ShareDeleteButton } from './ShareDeleteButton';
+import { ShareEditButton } from './ShareEditButton';
+
+export function SharesTable(props: DataTableProps) {
+ const { formatMessage, labels } = useMessages();
+ const { cloudMode } = useConfig();
+
+ const getUrl = (slug: string) => {
+ if (cloudMode) {
+ return `${process.env.cloudUrl}/share/${slug}`;
+ }
+ return `${window?.location.origin}${process.env.basePath || ''}/share/${slug}`;
+ };
+
+ return (
+
+
+ {({ slug }: any) => {
+ const url = getUrl(slug);
+ return (
+
+ {url}
+
+ );
+ }}
+
+
+ {(row: any) => }
+
+
+ {({ id, slug }: any) => {
+ return (
+
+
+
+
+ );
+ }}
+
+
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/settings/WebsiteSettings.tsx b/src/app/(main)/websites/[websiteId]/settings/WebsiteSettings.tsx
index 3970cdbd..d39c4531 100644
--- a/src/app/(main)/websites/[websiteId]/settings/WebsiteSettings.tsx
+++ b/src/app/(main)/websites/[websiteId]/settings/WebsiteSettings.tsx
@@ -1,14 +1,11 @@
import { Column } from '@umami/react-zen';
import { Panel } from '@/components/common/Panel';
-import { useWebsite } from '@/components/hooks';
import { WebsiteData } from './WebsiteData';
import { WebsiteEditForm } from './WebsiteEditForm';
import { WebsiteShareForm } from './WebsiteShareForm';
import { WebsiteTrackingCode } from './WebsiteTrackingCode';
export function WebsiteSettings({ websiteId }: { websiteId: string; openExternal?: boolean }) {
- const website = useWebsite();
-
return (
@@ -18,7 +15,7 @@ export function WebsiteSettings({ websiteId }: { websiteId: string; openExternal
-
+
diff --git a/src/app/(main)/websites/[websiteId]/settings/WebsiteShareForm.tsx b/src/app/(main)/websites/[websiteId]/settings/WebsiteShareForm.tsx
index 56c6f436..6ac4a404 100644
--- a/src/app/(main)/websites/[websiteId]/settings/WebsiteShareForm.tsx
+++ b/src/app/(main)/websites/[websiteId]/settings/WebsiteShareForm.tsx
@@ -1,93 +1,43 @@
-import {
- Button,
- Column,
- Form,
- FormButtons,
- FormSubmitButton,
- IconLabel,
- Label,
- Row,
- Switch,
- TextField,
-} from '@umami/react-zen';
-import { RefreshCcw } from 'lucide-react';
-import { useState } from 'react';
-import { useConfig, useMessages, useUpdateQuery } from '@/components/hooks';
-import { getRandomChars } from '@/lib/generate';
-
-const generateId = () => getRandomChars(16);
+import { Button, Column, Heading, Row, Text } from '@umami/react-zen';
+import { Plus } from 'lucide-react';
+import { useApi, useMessages, useModified, useWebsiteSharesQuery } from '@/components/hooks';
+import { SharesTable } from './SharesTable';
export interface WebsiteShareFormProps {
websiteId: string;
- shareId?: string;
- onSave?: () => void;
- onClose?: () => void;
}
-export function WebsiteShareForm({ websiteId, shareId, onSave, onClose }: WebsiteShareFormProps) {
- const { formatMessage, labels, messages, getErrorMessage } = useMessages();
- const [currentId, setCurrentId] = useState(shareId);
- const { mutateAsync, error, touch, toast } = useUpdateQuery(`/websites/${websiteId}`);
- const { cloudMode } = useConfig();
+export function WebsiteShareForm({ websiteId }: WebsiteShareFormProps) {
+ const { formatMessage, labels, messages } = useMessages();
+ const { data, isLoading } = useWebsiteSharesQuery({ websiteId });
+ const { post } = useApi();
+ const { touch } = useModified();
- const getUrl = (shareId: string) => {
- if (cloudMode) {
- return `${process.env.cloudUrl}/share/${shareId}`;
- }
-
- return `${window?.location.origin}${process.env.basePath || ''}/share/${shareId}`;
+ const handleCreate = async () => {
+ await post(`/websites/${websiteId}/shares`, { parameters: {} });
+ touch('shares');
};
- const url = getUrl(currentId);
-
- const handleGenerate = () => {
- setCurrentId(generateId());
- };
-
- const handleSwitch = () => {
- setCurrentId(currentId ? null : generateId());
- };
-
- const handleSave = async () => {
- const data = {
- shareId: currentId,
- };
- await mutateAsync(data, {
- onSuccess: async () => {
- toast(formatMessage(messages.saved));
- touch(`website:${websiteId}`);
- onSave?.();
- onClose?.();
- },
- });
- };
+ const shares = data?.data || [];
+ const hasShares = shares.length > 0;
return (
-
+
+
+ {formatMessage(labels.share)}
+
+
+ {hasShares ? (
+ <>
+ {formatMessage(messages.shareUrl)}
+
+ >
+ ) : (
+ {formatMessage(messages.noDataAvailable)}
+ )}
+
);
}
diff --git a/src/app/api/share/route.ts b/src/app/api/share/route.ts
index 99f5df0e..a772b4ab 100644
--- a/src/app/api/share/route.ts
+++ b/src/app/api/share/route.ts
@@ -1,5 +1,6 @@
import z from 'zod';
import { uuid } from '@/lib/crypto';
+import { getRandomChars } from '@/lib/generate';
import { parseRequest } from '@/lib/request';
import { json, unauthorized } from '@/lib/response';
import { anyObjectParam } from '@/lib/schema';
@@ -10,7 +11,7 @@ export async function POST(request: Request) {
const schema = z.object({
entityId: z.uuid(),
shareType: z.coerce.number().int(),
- slug: z.string().max(100),
+ slug: z.string().max(100).optional(),
parameters: anyObjectParam,
});
@@ -30,7 +31,7 @@ export async function POST(request: Request) {
id: uuid(),
entityId,
shareType,
- slug,
+ slug: slug || getRandomChars(16),
parameters,
});
diff --git a/src/app/api/websites/[websiteId]/route.ts b/src/app/api/websites/[websiteId]/route.ts
index b4c0e7e8..59f314d3 100644
--- a/src/app/api/websites/[websiteId]/route.ts
+++ b/src/app/api/websites/[websiteId]/route.ts
@@ -1,7 +1,6 @@
import { z } from 'zod';
-import { SHARE_ID_REGEX } from '@/lib/constants';
import { parseRequest } from '@/lib/request';
-import { badRequest, json, ok, serverError, unauthorized } from '@/lib/response';
+import { json, ok, unauthorized } from '@/lib/response';
import { canDeleteWebsite, canUpdateWebsite, canViewWebsite } from '@/permissions';
import { deleteWebsite, getWebsite, updateWebsite } from '@/queries/prisma';
@@ -33,7 +32,6 @@ export async function POST(
const schema = z.object({
name: z.string().optional(),
domain: z.string().optional(),
- shareId: z.string().regex(SHARE_ID_REGEX).nullable().optional(),
});
const { auth, body, error } = await parseRequest(request, schema);
@@ -43,23 +41,15 @@ export async function POST(
}
const { websiteId } = await params;
- const { name, domain, shareId } = body;
+ const { name, domain } = body;
if (!(await canUpdateWebsite(auth, websiteId))) {
return unauthorized();
}
- try {
- const website = await updateWebsite(websiteId, { name, domain, shareId });
+ const website = await updateWebsite(websiteId, { name, domain });
- return Response.json(website);
- } catch (e: any) {
- if (e.message.toLowerCase().includes('unique constraint') && e.message.includes('share_id')) {
- return badRequest({ message: 'That share ID is already taken.' });
- }
-
- return serverError(e);
- }
+ return Response.json(website);
}
export async function DELETE(
diff --git a/src/app/api/websites/[websiteId]/shares/route.ts b/src/app/api/websites/[websiteId]/shares/route.ts
new file mode 100644
index 00000000..db079d49
--- /dev/null
+++ b/src/app/api/websites/[websiteId]/shares/route.ts
@@ -0,0 +1,74 @@
+import { z } from 'zod';
+import { ENTITY_TYPE } from '@/lib/constants';
+import { uuid } from '@/lib/crypto';
+import { getRandomChars } from '@/lib/generate';
+import { parseRequest } from '@/lib/request';
+import { json, unauthorized } from '@/lib/response';
+import { anyObjectParam, filterParams, pagingParams } from '@/lib/schema';
+import { canUpdateWebsite, canViewWebsite } from '@/permissions';
+import { createShare, getSharesByEntityId } from '@/queries/prisma';
+
+export async function GET(
+ request: Request,
+ { params }: { params: Promise<{ websiteId: string }> },
+) {
+ const schema = z.object({
+ ...filterParams,
+ ...pagingParams,
+ });
+
+ const { auth, query, error } = await parseRequest(request, schema);
+
+ if (error) {
+ return error();
+ }
+
+ const { websiteId } = await params;
+ const { page, pageSize, search } = query;
+
+ if (!(await canViewWebsite(auth, websiteId))) {
+ return unauthorized();
+ }
+
+ const data = await getSharesByEntityId(websiteId, {
+ page,
+ pageSize,
+ search,
+ });
+
+ return json(data);
+}
+
+export async function POST(
+ request: Request,
+ { params }: { params: Promise<{ websiteId: string }> },
+) {
+ const schema = z.object({
+ parameters: anyObjectParam.optional(),
+ });
+
+ const { auth, body, error } = await parseRequest(request, schema);
+
+ if (error) {
+ return error();
+ }
+
+ const { websiteId } = await params;
+ const { parameters = {} } = body;
+
+ if (!(await canUpdateWebsite(auth, websiteId))) {
+ return unauthorized();
+ }
+
+ const slug = getRandomChars(16);
+
+ const share = await createShare({
+ id: uuid(),
+ entityId: websiteId,
+ shareType: ENTITY_TYPE.website,
+ slug,
+ parameters,
+ });
+
+ return json(share);
+}
diff --git a/src/components/hooks/index.ts b/src/components/hooks/index.ts
index e8e5c135..f47f11f0 100644
--- a/src/components/hooks/index.ts
+++ b/src/components/hooks/index.ts
@@ -51,6 +51,7 @@ export * from './queries/useWebsiteSegmentsQuery';
export * from './queries/useWebsiteSessionQuery';
export * from './queries/useWebsiteSessionStatsQuery';
export * from './queries/useWebsiteSessionsQuery';
+export * from './queries/useWebsiteSharesQuery';
export * from './queries/useWebsiteStatsQuery';
export * from './queries/useWebsitesQuery';
export * from './queries/useWebsiteValuesQuery';
diff --git a/src/components/hooks/queries/useWebsiteSharesQuery.ts b/src/components/hooks/queries/useWebsiteSharesQuery.ts
new file mode 100644
index 00000000..298e4d26
--- /dev/null
+++ b/src/components/hooks/queries/useWebsiteSharesQuery.ts
@@ -0,0 +1,20 @@
+import type { ReactQueryOptions } from '@/lib/types';
+import { useApi } from '../useApi';
+import { useModified } from '../useModified';
+import { usePagedQuery } from '../usePagedQuery';
+
+export function useWebsiteSharesQuery(
+ { websiteId }: { websiteId: string },
+ options?: ReactQueryOptions,
+) {
+ const { modified } = useModified('shares');
+ const { get } = useApi();
+
+ return usePagedQuery({
+ queryKey: ['websiteShares', { websiteId, modified }],
+ queryFn: pageParams => {
+ return get(`/websites/${websiteId}/shares`, pageParams);
+ },
+ ...options,
+ });
+}
diff --git a/src/queries/prisma/share.ts b/src/queries/prisma/share.ts
index e37dc95b..53246ffb 100644
--- a/src/queries/prisma/share.ts
+++ b/src/queries/prisma/share.ts
@@ -1,14 +1,15 @@
import type { Prisma } from '@/generated/prisma/client';
import prisma from '@/lib/prisma';
+import type { QueryFilters } from '@/lib/types';
export async function findShare(criteria: Prisma.ShareFindUniqueArgs) {
return prisma.client.share.findUnique(criteria);
}
-export async function getShare(entityId: string) {
+export async function getShare(shareId: string) {
return findShare({
where: {
- id: entityId,
+ id: shareId,
},
});
}
@@ -21,6 +22,23 @@ export async function getShareByCode(slug: string) {
});
}
+export async function getSharesByEntityId(entityId: string, filters?: QueryFilters) {
+ const { pagedQuery } = prisma;
+
+ return pagedQuery(
+ 'share',
+ {
+ where: {
+ entityId,
+ },
+ orderBy: {
+ createdAt: 'desc',
+ },
+ },
+ filters,
+ );
+}
+
export async function createShare(
data: Prisma.ShareCreateInput | Prisma.ShareUncheckedCreateInput,
) {