New share URL form.

This commit is contained in:
Mike Cao 2025-06-21 01:45:36 -07:00
parent 543674c7f2
commit 6d1603fa28
14 changed files with 144 additions and 116 deletions

View file

@ -80,7 +80,7 @@
"@react-spring/web": "^9.7.3",
"@svgr/cli": "^8.1.0",
"@tanstack/react-query": "^5.80.10",
"@umami/react-zen": "^0.138.0",
"@umami/react-zen": "^0.139.0",
"@umami/redis-client": "^0.27.0",
"bcryptjs": "^2.4.3",
"chalk": "^4.1.1",

10
pnpm-lock.yaml generated
View file

@ -45,8 +45,8 @@ importers:
specifier: ^5.80.10
version: 5.80.10(react@19.1.0)
'@umami/react-zen':
specifier: ^0.138.0
version: 0.138.0(@babel/core@7.27.1)(@types/react@19.1.8)(babel-plugin-react-compiler@19.1.0-rc.2)(immer@9.0.21)(use-sync-external-store@1.5.0(react@19.1.0))
specifier: ^0.139.0
version: 0.139.0(@babel/core@7.27.1)(@types/react@19.1.8)(babel-plugin-react-compiler@19.1.0-rc.2)(immer@9.0.21)(use-sync-external-store@1.5.0(react@19.1.0))
'@umami/redis-client':
specifier: ^0.27.0
version: 0.27.0
@ -2549,8 +2549,8 @@ packages:
resolution: {integrity: sha512-xoh5rJ+tgsRKoXnkBPFRLZ7rjKM0AfVbC68UZ/ECXoDbfggb9RbEySN359acY1vS3qZ0jVTVWzbtfapwm5ztxw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@umami/react-zen@0.138.0':
resolution: {integrity: sha512-MlrLu21/WjmzPnYRQgQTofb7o+1fvL8XF7EbCjZFKjW+VHz5Cg6nOZWiFBxGWWCIAWfIVZpvczvK+thi4hHigg==}
'@umami/react-zen@0.139.0':
resolution: {integrity: sha512-NRf27+05z78DLFxK3aQUBfhZW7covl6qtS4OcaBUbZ71VZ7eeRVg7SU7Cn3NvkXlcI16t6bbLXGW4HjvfBhXsw==}
'@umami/redis-client@0.27.0':
resolution: {integrity: sha512-SbHTpxhgeZyTBUSp2zdZM+XUtpsaSL4Tad8QXIEhEtjWhvvfoornyT5kLuyYCVtzSAT4daALeGmOO1z6EE1KcA==}
@ -9733,7 +9733,7 @@ snapshots:
'@typescript-eslint/types': 8.34.1
eslint-visitor-keys: 4.2.1
'@umami/react-zen@0.138.0(@babel/core@7.27.1)(@types/react@19.1.8)(babel-plugin-react-compiler@19.1.0-rc.2)(immer@9.0.21)(use-sync-external-store@1.5.0(react@19.1.0))':
'@umami/react-zen@0.139.0(@babel/core@7.27.1)(@types/react@19.1.8)(babel-plugin-react-compiler@19.1.0-rc.2)(immer@9.0.21)(use-sync-external-store@1.5.0(react@19.1.0))':
dependencies:
'@fontsource/jetbrains-mono': 5.2.6
'@internationalized/date': 3.8.2

View file

@ -17,7 +17,7 @@ export function TeamLeaveButton({ teamId, teamName }: { teamId: string; teamName
return (
<DialogTrigger>
<Button variant="secondary">
<Button>
<Icon>
<LogOut />
</Icon>

View file

@ -15,7 +15,7 @@ export function TeamsJoinButton() {
return (
<DialogTrigger>
<Button variant="secondary">
<Button>
<Icon>
<AddUser />
</Icon>

View file

@ -1,90 +0,0 @@
import {
Form,
FormField,
FormButtons,
TextField,
Button,
Switch,
FormSubmitButton,
Box,
useToast,
} from '@umami/react-zen';
import { useContext, useState } from 'react';
import { getRandomChars } from '@/lib/crypto';
import { useApi, useMessages, useModified } from '@/components/hooks';
import { WebsiteContext } from '@/app/(main)/websites/[websiteId]/WebsiteProvider';
const generateId = () => getRandomChars(16);
export function ShareUrl({ hostUrl, onSave }: { hostUrl?: string; onSave?: () => void }) {
const website = useContext(WebsiteContext);
const { domain, shareId } = website;
const { formatMessage, labels, messages } = useMessages();
const [id, setId] = useState(shareId);
const { post, useMutation } = useApi();
const { mutate, error, isPending } = useMutation({
mutationFn: (data: any) => post(`/websites/${website.id}`, data),
});
const { touch } = useModified();
const { toast } = useToast();
const url = `${hostUrl || window?.location.origin || ''}${
process.env.basePath || ''
}/share/${id}/${domain}`;
const handleGenerate = () => {
setId(generateId());
};
const handleSwitch = (checked: boolean) => {
const data = {
name: website.name,
domain: website.domain,
shareId: checked ? generateId() : null,
};
mutate(data, {
onSuccess: async () => {
toast(formatMessage(messages.saved));
touch(`website:${website.id}`);
onSave?.();
},
});
setId(data.shareId);
};
const handleSave = () => {
mutate(
{ name: website.name, domain: website.domain, shareId: id },
{
onSuccess: async () => {
toast(formatMessage(messages.saved));
touch(`website:${website.id}`);
onSave?.();
},
},
);
};
return (
<>
<Box marginBottom="6">
<Switch defaultSelected={!!id} isSelected={!!id} onChange={handleSwitch}>
{formatMessage(labels.enableShareUrl)}
</Switch>
</Box>
{id && (
<Form onSubmit={handleSave} error={error} values={{ id, url }}>
<FormField label={formatMessage(messages.shareUrl)} name="url">
<TextField isReadOnly allowCopy />
</FormField>
<FormButtons justifyContent="space-between">
<Button onPress={handleGenerate}>{formatMessage(labels.regenerate)}</Button>
<FormSubmitButton variant="primary" isDisabled={id === shareId} isLoading={isPending}>
{formatMessage(labels.save)}
</FormSubmitButton>
</FormButtons>
</Form>
)}
</>
);
}

View file

@ -52,9 +52,7 @@ export function WebsiteData({ websiteId, onSave }: { websiteId: string; onSave?:
description={formatMessage(messages.transferWebsite)}
>
<DialogTrigger>
<Button variant="secondary" isDisabled={!canTransferWebsite}>
{formatMessage(labels.transfer)}
</Button>
<Button isDisabled={!canTransferWebsite}>{formatMessage(labels.transfer)}</Button>
<Modal>
<Dialog title={formatMessage(labels.transferWebsite)}>
{({ close }) => (
@ -70,7 +68,7 @@ export function WebsiteData({ websiteId, onSave }: { websiteId: string; onSave?:
description={formatMessage(messages.resetWebsiteWarning)}
>
<DialogTrigger>
<Button variant="secondary">{formatMessage(labels.reset)}</Button>
<Button>{formatMessage(labels.reset)}</Button>
<Modal>
<Dialog title={formatMessage(labels.resetWebsite)}>
{({ close }) => (

View file

@ -4,7 +4,7 @@ import { WebsiteContext } from '@/app/(main)/websites/[websiteId]/WebsiteProvide
import { useMessages } from '@/components/hooks';
import { Globe, Arrow } from '@/components/icons';
import { SectionHeader } from '@/components/common/SectionHeader';
import { ShareUrl } from './ShareUrl';
import { WebsiteShareForm } from './WebsiteShareForm';
import { TrackingCode } from './TrackingCode';
import { WebsiteData } from './WebsiteData';
import { WebsiteEditForm } from './WebsiteEditForm';
@ -48,7 +48,7 @@ export function WebsiteSettings({
<TrackingCode websiteId={websiteId} />
</TabPanel>
<TabPanel id="share">
<ShareUrl />
<WebsiteShareForm />
</TabPanel>
<TabPanel id="data">
<WebsiteData websiteId={websiteId} />

View file

@ -0,0 +1,95 @@
import {
Form,
FormButtons,
TextField,
Button,
Switch,
FormSubmitButton,
Column,
Icon,
Grid,
Label,
useToast,
TooltipTrigger,
Tooltip,
} from '@umami/react-zen';
import { useState } from 'react';
import { getRandomChars } from '@/lib/crypto';
import { useApi, useMessages, useModified } from '@/components/hooks';
import { Refresh } from '@/components/icons';
const generateId = () => getRandomChars(16);
export interface WebsiteShareFormProps {
websiteId: string;
shareId: string;
onSave?: () => void;
onClose?: () => void;
}
export function WebsiteShareForm({ websiteId, shareId, onSave, onClose }: WebsiteShareFormProps) {
const { formatMessage, labels, messages } = useMessages();
const [id, setId] = useState(shareId);
const { post, useMutation } = useApi();
const { mutate, error, isPending } = useMutation({
mutationFn: (data: any) => post(`/websites/${websiteId}`, data),
});
const { touch } = useModified();
const { toast } = useToast();
const url = `${window?.location.origin || ''}${process.env.basePath || ''}/share/${id}`;
const handleGenerate = () => {
setId(generateId());
};
const handleSwitch = () => {
setId(id ? null : generateId());
};
const handleSave = () => {
const data = {
shareId: id,
};
mutate(data, {
onSuccess: async () => {
toast(formatMessage(messages.saved));
touch(`website:${websiteId}`);
onSave?.();
onClose?.();
},
});
};
return (
<Form onSubmit={handleSave} error={error} values={{ url }}>
<Column gap>
<Switch isSelected={!!id} onChange={handleSwitch}>
{formatMessage(labels.enableShareUrl)}
</Switch>
{id && (
<Column>
<Label>{formatMessage(labels.shareUrl)}</Label>
<Grid columns="1fr auto" gap>
<TextField value={url} isReadOnly allowCopy />
<TooltipTrigger>
<Button onPress={handleGenerate} variant="quiet" size="sm">
<Icon>
<Refresh />
</Icon>
</Button>
<Tooltip>{formatMessage(labels.regenerate)}</Tooltip>
</TooltipTrigger>
</Grid>
</Column>
)}
<FormButtons>
<Button onPress={onClose}>{formatMessage(labels.cancel)}</Button>
<FormSubmitButton isDisabled={false} isLoading={isPending}>
{formatMessage(labels.save)}
</FormSubmitButton>
</FormButtons>
</Column>
</Form>
);
}

View file

@ -1,9 +1,11 @@
import { Button, Icon, Text, Row } from '@umami/react-zen';
import { Button, Icon, Text, Row, DialogTrigger, Dialog, Modal } from '@umami/react-zen';
import { PageHeader } from '@/components/common/PageHeader';
import { useWebsite } from '@/components/hooks/useWebsite';
import { Share, Edit } from '@/components/icons';
import { Favicon } from '@/components/common/Favicon';
import { ActiveUsers } from '@/components/metrics/ActiveUsers';
import { WebsiteShareForm } from '@/app/(main)/settings/websites/[websiteId]/WebsiteShareForm';
import { useMessages } from '@/components/hooks';
export function WebsiteHeader() {
const website = useWebsite();
@ -12,12 +14,7 @@ export function WebsiteHeader() {
<PageHeader title={website.name} icon={<Favicon domain={website.domain} />} showBorder={false}>
<Row alignItems="center" gap>
<ActiveUsers websiteId={website.id} />
<Button>
<Icon>
<Share />
</Icon>
<Text>Share</Text>
</Button>
<ShareButton websiteId={website.id} shareId={website.shareId} />
<Button>
<Icon>
<Edit />
@ -28,3 +25,25 @@ export function WebsiteHeader() {
</PageHeader>
);
}
const ShareButton = ({ websiteId, shareId }) => {
const { formatMessage, labels } = useMessages();
return (
<DialogTrigger>
<Button>
<Icon>
<Share />
</Icon>
<Text>Share</Text>
</Button>
<Modal>
<Dialog title={formatMessage(labels.share)} style={{ width: 600 }}>
{({ close }) => {
return <WebsiteShareForm websiteId={websiteId} shareId={shareId} onClose={close} />;
}}
</Dialog>
</Modal>
</DialogTrigger>
);
};

View file

@ -2,8 +2,9 @@
import { createContext, ReactNode, useEffect } from 'react';
import { useModified, useWebsiteQuery } from '@/components/hooks';
import { Loading } from '@umami/react-zen';
import { Website } from '@/generated/prisma/client';
export const WebsiteContext = createContext(null);
export const WebsiteContext = createContext<Website>(null);
export function WebsiteProvider({
websiteId,

View file

@ -31,8 +31,8 @@ export async function POST(
{ params }: { params: Promise<{ websiteId: string }> },
) {
const schema = z.object({
name: z.string(),
domain: z.string(),
name: z.string().optional(),
domain: z.string().optional(),
shareId: z.string().regex(SHARE_ID_REGEX).nullable().optional(),
});

View file

@ -26,7 +26,7 @@ export * from '@/app/(main)/settings/teams/TeamsJoinButton';
export * from '@/app/(main)/settings/teams/TeamsTable';
export * from '@/app/(main)/settings/teams/WebsiteTags';
export * from '@/app/(main)/settings/websites/[websiteId]/ShareUrl';
export * from '@/app/(main)/settings/websites/[websiteId]/WebsiteShareForm';
export * from '@/app/(main)/settings/websites/[websiteId]/TrackingCode';
export * from '@/app/(main)/settings/websites/[websiteId]/WebsiteData';
export * from '@/app/(main)/settings/websites/[websiteId]/WebsiteDeleteForm';

View file

@ -198,7 +198,7 @@ export const CHART_COLORS = [
export const DOMAIN_REGEX =
/^(localhost(:[1-9]\d{0,4})?|((?=[a-z0-9-_]{1,63}\.)(xn--)?[a-z0-9-_]+(-[a-z0-9-_]+)*\.)+(xn--)?[a-z0-9-_]{2,63})$/;
export const SHARE_ID_REGEX = /^[a-zA-Z0-9]{8,16}$/;
export const SHARE_ID_REGEX = /^[a-zA-Z0-9]{8,50}$/;
export const DATETIME_REGEX =
/^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\.[0-9]{3}(Z|\+[0-9]{2}:[0-9]{2})?)?$/;

View file

@ -6,6 +6,11 @@ body {
background-color: var(--background-color);
width: 100%;
min-height: 100vh;
overflow: hidden;
}
html[style*='padding-right'] {
padding-right: 0 !important;
}
a,