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 ( +
+ + + + + + + {onClose && ( + + )} + {formatMessage(labels.save)} + + +
+ ); +} 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.enableShareUrl)} - - {currentId && ( - - - - - - - - - - )} - - - {onClose && } - {formatMessage(labels.save)} - - - -
+ + + {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, ) {