umami/src/app/(main)/links/LinkEditForm.tsx
crbon b915f15ed9 feat(analytics): add custom metadata support for shared links
- Add custom title field for user-defined link names
- Implement custom OG image URL input for social media previews
- Enable Open Graph metadata customization (description, type, etc.)
- Add link slug/path customization for personalized URLs
- Update link creation form with new metadata fields
- Extend database schema to store custom link properties
- Add validation for OG image URLs and metadata inputs

This allows users to fully customize their shared analytics links with
custom titles, Open Graph images, and other metadata for better social
media presentation and link management.
2026-01-08 14:43:41 +10:00

150 lines
4 KiB
TypeScript

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 <Loading placement="absolute" />;
}
return (
<Form onSubmit={handleSubmit} error={getErrorMessage(error)} defaultValues={{ slug, ...data }}>
{({ setValue }) => {
return (
<>
<FormField
label={formatMessage(labels.name)}
name="name"
rules={{ required: formatMessage(labels.required) }}
>
<TextField autoComplete="off" autoFocus />
</FormField>
<FormField
label={formatMessage(labels.destinationUrl)}
name="url"
rules={{ required: formatMessage(labels.required), validate: checkUrl }}
>
<TextField placeholder="https://example.com" autoComplete="off" />
</FormField>
<FormField label={formatMessage(labels.title)} name="title">
<TextField autoComplete="off" />
</FormField>
<FormField label={formatMessage(labels.description)} name="description">
<TextField autoComplete="off" />
</FormField>
<FormField label="OG Image URL" name="image">
<TextField autoComplete="off" />
</FormField>
<Column>
<Label>{formatMessage(labels.link)}</Label>
<Row alignItems="center" gap>
<FormField
name="slug"
rules={{ required: formatMessage(labels.required) }}
style={{ width: '100%' }}
>
<TextField autoComplete="off" />
</FormField>
<Button
variant="quiet"
onPress={() => setValue('slug', handleSlug(), { shouldDirty: true })}
>
<Icon>
<RefreshCw />
</Icon>
</Button>
</Row>
</Column>
<Row justifyContent="flex-end" paddingTop="3" gap="3">
{onClose && (
<Button isDisabled={isPending} onPress={onClose}>
{formatMessage(labels.cancel)}
</Button>
)}
<FormSubmitButton>{formatMessage(labels.save)}</FormSubmitButton>
</Row>
</>
);
}}
</Form>
);
}