Share page changes.

This commit is contained in:
Mike Cao 2026-01-24 02:47:09 -08:00
parent c9f6653b62
commit 4a09f2bff6
11 changed files with 60 additions and 140 deletions

1
.gitignore vendored
View file

@ -32,6 +32,7 @@ pm2.yml
.vscode .vscode
.tool-versions .tool-versions
.claude .claude
nul
# debug # debug
npm-debug.log* npm-debug.log*

View file

@ -11,7 +11,7 @@
}, },
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "next dev -p 3003 --turbo", "dev": "next dev -p 3002 --turbo",
"build": "npm-run-all check-env build-db check-db build-tracker build-geo build-app", "build": "npm-run-all check-env build-db check-db build-tracker build-geo build-app",
"start": "next start", "start": "next start",
"build-docker": "npm-run-all build-db build-tracker build-geo build-app", "build-docker": "npm-run-all build-db build-tracker build-geo build-app",

View file

@ -1,101 +0,0 @@
import {
Button,
Checkbox,
Column,
Form,
FormField,
FormSubmitButton,
Row,
Text,
TextField,
} from '@umami/react-zen';
import { useState } from 'react';
import { useApi, useMessages, useModified } from '@/components/hooks';
import { SHARE_NAV_ITEMS } from './constants';
export interface ShareCreateFormProps {
websiteId: string;
onSave?: () => void;
onClose?: () => void;
}
export function ShareCreateForm({ websiteId, onSave, onClose }: ShareCreateFormProps) {
const { formatMessage, labels } = useMessages();
const { post } = useApi();
const { touch } = useModified();
const [isPending, setIsPending] = useState(false);
// Build default values - only overview and events enabled by default
const defaultValues: Record<string, boolean> = {};
SHARE_NAV_ITEMS.forEach(section => {
section.items.forEach(item => {
defaultValues[item.id] = item.id === 'overview' || item.id === 'events';
});
});
// Get all item ids for validation
const allItemIds = SHARE_NAV_ITEMS.flatMap(section => section.items.map(item => item.id));
const handleSubmit = async (data: any) => {
setIsPending(true);
try {
const parameters: Record<string, boolean> = {};
SHARE_NAV_ITEMS.forEach(section => {
section.items.forEach(item => {
parameters[item.id] = data[item.id] ?? false;
});
});
await post(`/websites/${websiteId}/shares`, { name: data.name, parameters });
touch('shares');
onSave?.();
onClose?.();
} finally {
setIsPending(false);
}
};
return (
<Form onSubmit={handleSubmit} defaultValues={defaultValues}>
{({ watch }) => {
const values = watch();
const hasSelection = allItemIds.some(id => values[id]);
return (
<Column gap="3">
<FormField
label={formatMessage(labels.name)}
name="name"
rules={{ required: formatMessage(labels.required) }}
>
<TextField autoComplete="off" autoFocus />
</FormField>
{SHARE_NAV_ITEMS.map(section => (
<Column key={section.section} gap="1">
<Text size="2" weight="bold">
{formatMessage((labels as any)[section.section])}
</Text>
<Column gap="1">
{section.items.map(item => (
<FormField key={item.id} name={item.id}>
<Checkbox>{formatMessage((labels as any)[item.label])}</Checkbox>
</FormField>
))}
</Column>
</Column>
))}
<Row justifyContent="flex-end" paddingTop="3" gap="3">
{onClose && (
<Button isDisabled={isPending} onPress={onClose}>
{formatMessage(labels.cancel)}
</Button>
)}
<FormSubmitButton isDisabled={isPending || !hasSelection}>
{formatMessage(labels.save)}
</FormSubmitButton>
</Row>
</Column>
);
}}
</Form>
);
}

View file

@ -14,25 +14,30 @@ import {
} from '@umami/react-zen'; } from '@umami/react-zen';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useApi, useConfig, useMessages, useModified } from '@/components/hooks'; import { useApi, useConfig, useMessages, useModified } from '@/components/hooks';
import { useUpdateQuery } from '@/components/hooks/queries/useUpdateQuery';
import { SHARE_NAV_ITEMS } from './constants'; import { SHARE_NAV_ITEMS } from './constants';
export function ShareEditForm({ export function ShareEditForm({
shareId, shareId,
websiteId,
onSave, onSave,
onClose, onClose,
}: { }: {
shareId: string; shareId?: string;
websiteId?: string;
onSave?: () => void; onSave?: () => void;
onClose?: () => void; onClose?: () => void;
}) { }) {
const { formatMessage, labels, messages, getErrorMessage } = useMessages(); const { formatMessage, labels, getErrorMessage } = useMessages();
const { mutateAsync, error, isPending, touch, toast } = useUpdateQuery(`/share/id/${shareId}`);
const { cloudMode } = useConfig(); const { cloudMode } = useConfig();
const { get } = useApi(); const { get, post } = useApi();
const { touch } = useModified();
const { modified } = useModified('shares'); const { modified } = useModified('shares');
const [share, setShare] = useState<any>(null); const [share, setShare] = useState<any>(null);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(!!shareId);
const [isPending, setIsPending] = useState(false);
const [error, setError] = useState<any>(null);
const isEditing = !!shareId;
const getUrl = (slug: string) => { const getUrl = (slug: string) => {
if (cloudMode) { if (cloudMode) {
@ -42,6 +47,8 @@ export function ShareEditForm({
}; };
useEffect(() => { useEffect(() => {
if (!shareId) return;
const loadShare = async () => { const loadShare = async () => {
setIsLoading(true); setIsLoading(true);
try { try {
@ -62,24 +69,30 @@ export function ShareEditForm({
}); });
}); });
await mutateAsync( setIsPending(true);
{ name: data.name, slug: share.slug, parameters }, setError(null);
{
onSuccess: async () => { try {
toast(formatMessage(messages.saved)); if (isEditing) {
await post(`/share/id/${shareId}`, { name: data.name, slug: share.slug, parameters });
} else {
await post(`/websites/${websiteId}/shares`, { name: data.name, parameters });
}
touch('shares'); touch('shares');
onSave?.(); onSave?.();
onClose?.(); onClose?.();
}, } catch (e) {
}, setError(e);
); } finally {
setIsPending(false);
}
}; };
if (isLoading) { if (isLoading) {
return <Loading placement="absolute" />; return <Loading placement="absolute" />;
} }
const url = getUrl(share?.slug || ''); const url = isEditing ? getUrl(share?.slug || '') : null;
// Build default values from share parameters // Build default values from share parameters
const defaultValues: Record<string, any> = { const defaultValues: Record<string, any> = {
@ -103,16 +116,18 @@ export function ShareEditForm({
return ( return (
<Column gap="6"> <Column gap="6">
{url && (
<Column> <Column>
<Label>{formatMessage(labels.shareUrl)}</Label> <Label>{formatMessage(labels.shareUrl)}</Label>
<TextField value={url} isReadOnly allowCopy /> <TextField value={url} isReadOnly allowCopy />
</Column> </Column>
)}
<FormField <FormField
label={formatMessage(labels.name)} label={formatMessage(labels.name)}
name="name" name="name"
rules={{ required: formatMessage(labels.required) }} rules={{ required: formatMessage(labels.required) }}
> >
<TextField autoComplete="off" /> <TextField autoComplete="off" autoFocus={!isEditing} />
</FormField> </FormField>
<Grid columns="repeat(auto-fit, minmax(150px, 1fr))" gap="3"> <Grid columns="repeat(auto-fit, minmax(150px, 1fr))" gap="3">
{SHARE_NAV_ITEMS.map(section => ( {SHARE_NAV_ITEMS.map(section => (
@ -134,7 +149,10 @@ export function ShareEditForm({
{formatMessage(labels.cancel)} {formatMessage(labels.cancel)}
</Button> </Button>
)} )}
<FormSubmitButton variant="primary" isDisabled={!hasSelection || !values.name}> <FormSubmitButton
variant="primary"
isDisabled={isPending || !hasSelection || !values.name}
>
{formatMessage(labels.save)} {formatMessage(labels.save)}
</FormSubmitButton> </FormSubmitButton>
</Row> </Row>

View file

@ -2,7 +2,7 @@ import { Column, Heading, Row, Text } from '@umami/react-zen';
import { Plus } from 'lucide-react'; import { Plus } from 'lucide-react';
import { useMessages, useWebsiteSharesQuery } from '@/components/hooks'; import { useMessages, useWebsiteSharesQuery } from '@/components/hooks';
import { DialogButton } from '@/components/input/DialogButton'; import { DialogButton } from '@/components/input/DialogButton';
import { ShareCreateForm } from './ShareCreateForm'; import { ShareEditForm } from './ShareEditForm';
import { SharesTable } from './SharesTable'; import { SharesTable } from './SharesTable';
export interface WebsiteShareFormProps { export interface WebsiteShareFormProps {
@ -25,9 +25,9 @@ export function WebsiteShareForm({ websiteId }: WebsiteShareFormProps) {
label={formatMessage(labels.add)} label={formatMessage(labels.add)}
title={formatMessage(labels.share)} title={formatMessage(labels.share)}
variant="primary" variant="primary"
width="400px" width="600px"
> >
{({ close }) => <ShareCreateForm websiteId={websiteId} onClose={close} />} {({ close }) => <ShareEditForm websiteId={websiteId} onClose={close} />}
</DialogButton> </DialogButton>
</Row> </Row>
{hasShares ? ( {hasShares ? (

View file

@ -4,14 +4,9 @@ import { createToken } from '@/lib/jwt';
import prisma from '@/lib/prisma'; import prisma from '@/lib/prisma';
import redis from '@/lib/redis'; import redis from '@/lib/redis';
import { json, notFound } from '@/lib/response'; import { json, notFound } from '@/lib/response';
import type { WhiteLabel } from '@/lib/types';
import { getShareByCode, getWebsite } from '@/queries/prisma'; import { getShareByCode, getWebsite } from '@/queries/prisma';
export interface WhiteLabel {
name: string;
url: string;
image: string;
}
async function getAccountId(website: { userId?: string; teamId?: string }): Promise<string | null> { async function getAccountId(website: { userId?: string; teamId?: string }): Promise<string | null> {
if (website.userId) { if (website.userId) {
return website.userId; return website.userId;

View file

@ -1,8 +1,8 @@
import { Row, Text } from '@umami/react-zen'; import { Row, Text } from '@umami/react-zen';
import type { WhiteLabel } from '@/app/api/share/[shareId]/route';
import { CURRENT_VERSION, HOMEPAGE_URL } from '@/lib/constants'; import { CURRENT_VERSION, HOMEPAGE_URL } from '@/lib/constants';
import type { WhiteLabel } from '@/lib/types';
export function Footer({ whiteLabel }: { whiteLabel?: WhiteLabel }) { export function ShareFooter({ whiteLabel }: { whiteLabel?: WhiteLabel }) {
if (whiteLabel) { if (whiteLabel) {
return ( return (
<Row as="footer" paddingY="6" justifyContent="flex-end"> <Row as="footer" paddingY="6" justifyContent="flex-end">

View file

@ -1,10 +1,10 @@
import { Icon, Row, Text, ThemeButton } from '@umami/react-zen'; import { Icon, Row, Text, ThemeButton } from '@umami/react-zen';
import type { WhiteLabel } from '@/app/api/share/[shareId]/route';
import { LanguageButton } from '@/components/input/LanguageButton'; import { LanguageButton } from '@/components/input/LanguageButton';
import { PreferencesButton } from '@/components/input/PreferencesButton'; import { PreferencesButton } from '@/components/input/PreferencesButton';
import { Logo } from '@/components/svg'; import { Logo } from '@/components/svg';
import type { WhiteLabel } from '@/lib/types';
export function Header({ whiteLabel }: { whiteLabel?: WhiteLabel }) { export function ShareHeader({ whiteLabel }: { whiteLabel?: WhiteLabel }) {
const logoUrl = whiteLabel?.url || 'https://umami.is'; const logoUrl = whiteLabel?.url || 'https://umami.is';
const logoName = whiteLabel?.name || 'umami'; const logoName = whiteLabel?.name || 'umami';
const logoImage = whiteLabel?.image; const logoImage = whiteLabel?.image;

View file

@ -115,7 +115,7 @@ export function SharePage({ shareId, path = '' }: { shareId: string; path?: stri
const PageComponent = PAGE_COMPONENTS[pageKey] || WebsitePage; const PageComponent = PAGE_COMPONENTS[pageKey] || WebsitePage;
return ( return (
<Column backgroundColor="2" minHeight="100%"> <Column backgroundColor="2">
<Grid columns={{ xs: '1fr', lg: 'auto 1fr' }} width="100%" height="100%"> <Grid columns={{ xs: '1fr', lg: 'auto 1fr' }} width="100%" height="100%">
<Column <Column
display={{ xs: 'none', lg: 'flex' }} display={{ xs: 'none', lg: 'flex' }}

View file

@ -31,6 +31,7 @@ export function PageBody({
<Column <Column
{...props} {...props}
width="100%" width="100%"
minHeight="100vh"
paddingBottom="6" paddingBottom="6"
maxWidth={maxWidth} maxWidth={maxWidth}
paddingX={{ xs: '3', md: '6' }} paddingX={{ xs: '3', md: '6' }}

View file

@ -141,3 +141,9 @@ export interface ApiError extends Error {
code?: string; code?: string;
message: string; message: string;
} }
export interface WhiteLabel {
name: string;
url: string;
image: string;
}