This commit is contained in:
crbon 2026-01-22 07:27:59 +00:00 committed by GitHub
commit d18c069fe5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 212 additions and 53 deletions

View file

@ -0,0 +1,5 @@
-- AlterTable
ALTER TABLE "link"
ADD COLUMN "og_description" VARCHAR(500),
ADD COLUMN "og_image_url" VARCHAR(500),
ADD COLUMN "og_title" VARCHAR(500);

View file

@ -277,15 +277,18 @@ model Revenue {
}
model Link {
id String @id() @unique() @map("link_id") @db.Uuid
name String @db.VarChar(100)
url String @db.VarChar(500)
slug String @unique() @db.VarChar(100)
userId String? @map("user_id") @db.Uuid
teamId String? @map("team_id") @db.Uuid
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamptz(6)
updatedAt DateTime? @updatedAt @map("updated_at") @db.Timestamptz(6)
deletedAt DateTime? @map("deleted_at") @db.Timestamptz(6)
id String @id() @unique() @map("link_id") @db.Uuid
name String @db.VarChar(100)
url String @db.VarChar(500)
slug String @unique() @db.VarChar(100)
ogTitle String? @map("og_title") @db.VarChar(500)
ogDescription String? @map("og_description") @db.VarChar(500)
ogImageUrl String? @map("og_image_url") @db.VarChar(500)
userId String? @map("user_id") @db.Uuid
teamId String? @map("team_id") @db.Uuid
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamptz(6)
updatedAt DateTime? @updatedAt @map("updated_at") @db.Timestamptz(6)
deletedAt DateTime? @map("deleted_at") @db.Timestamptz(6)
user User? @relation("user", fields: [userId], references: [id])
team Team? @relation(fields: [teamId], references: [id])

View file

@ -1,5 +1,6 @@
export const dynamic = 'force-dynamic';
import { isbot } from 'isbot';
import { NextResponse } from 'next/server';
import { POST } from '@/app/api/send/route';
import type { Link } from '@/generated/prisma/client';
@ -7,6 +8,23 @@ import redis from '@/lib/redis';
import { notFound } from '@/lib/response';
import { findLink } from '@/queries/prisma';
function escapeHtml(str: string): string {
return str
.replace(/&/g, '&')
.replace(/"/g, '"')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/'/g, '&#39;');
}
function metaTag(property: string, content: string | undefined, isName = false): string {
if (!content) return '';
const escaped = escapeHtml(content);
return isName
? `<meta name="${property}" content="${escaped}">`
: `<meta property="${property}" content="${escaped}">`;
}
export async function GET(request: Request, { params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
@ -40,6 +58,48 @@ export async function GET(request: Request, { params }: { params: Promise<{ slug
}
}
const userAgent = request.headers.get('user-agent') || '';
if (isbot(userAgent)) {
const ogTitle = link.ogTitle || link.name;
const ogDescription = link.ogDescription || undefined;
const ogImageUrl = link.ogImageUrl || undefined;
const twitterCard = ogImageUrl ? 'summary_large_image' : 'summary';
return new Response(
`
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>${escapeHtml(ogTitle)}</title>
${metaTag('title', ogTitle, true)}
${metaTag('description', ogDescription, true)}
${metaTag('og:type', 'website')}
${metaTag('og:site_name', 'Umami')}
${metaTag('og:title', ogTitle)}
${metaTag('og:url', request.url)}
${metaTag('og:description', ogDescription)}
${metaTag('og:image', ogImageUrl)}
<meta name="twitter:card" content="${twitterCard}">
${metaTag('twitter:title', ogTitle, true)}
${metaTag('twitter:description', ogDescription, true)}
${metaTag('twitter:image', ogImageUrl, true)}
</head>
<body>
<p>Redirecting to ${escapeHtml(link.url)}...</p>
</body>
</html>
`,
{
headers: {
'content-type': 'text/html',
'cache-control': 's-maxage=300, stale-while-revalidate',
},
},
);
}
const payload = {
type: 'event',
payload: {

View file

@ -10,10 +10,10 @@ import {
Row,
TextField,
} from '@umami/react-zen';
import { useEffect, useState } from 'react';
import { useState } from 'react';
import { useConfig, useLinkQuery, useMessages } from '@/components/hooks';
import { useUpdateQuery } from '@/components/hooks/queries/useUpdateQuery';
import { RefreshCw } from '@/components/icons';
import { ChevronDown, ChevronRight, RefreshCw } from '@/components/icons';
import { LINKS_URL } from '@/lib/constants';
import { getRandomChars } from '@/lib/generate';
import { isValidUrl } from '@/lib/url';
@ -42,10 +42,15 @@ export function LinkEditForm({
const { linksUrl } = useConfig();
const hostUrl = linksUrl || LINKS_URL;
const { data, isLoading } = useLinkQuery(linkId);
const [slug, setSlug] = useState(generateId());
const [initialSlug] = useState(() => generateId());
const [showAdvanced, setShowAdvanced] = useState(false);
const handleSubmit = async (data: any) => {
await mutateAsync(data, {
const handleSubmit = async (formData: any) => {
const { slug: formSlug, ...rest } = formData;
// Only include slug if creating new link or if it was modified
const payload = !linkId || formSlug !== data?.slug ? formData : rest;
await mutateAsync(payload, {
onSuccess: async () => {
toast(formatMessage(messages.saved));
touch('links');
@ -55,13 +60,7 @@ export function LinkEditForm({
});
};
const handleSlug = () => {
const slug = generateId();
setSlug(slug);
return slug;
};
const handleSlug = () => generateId();
const checkUrl = (url: string) => {
if (!isValidUrl(url)) {
@ -70,19 +69,35 @@ export function LinkEditForm({
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 }) => {
<Form
onSubmit={handleSubmit}
error={getErrorMessage(error)}
{...(linkId
? {
values: {
...data,
ogTitle: data?.ogTitle || '',
ogDescription: data?.ogDescription || '',
ogImageUrl: data?.ogImageUrl || '',
},
}
: {
defaultValues: {
slug: initialSlug,
ogTitle: '',
ogDescription: '',
ogImageUrl: '',
},
})}
>
{({ setValue, watch }) => {
const currentSlug = watch('slug') ?? initialSlug;
return (
<>
<FormField
@ -101,25 +116,15 @@ export function LinkEditForm({
<TextField placeholder="https://example.com" autoComplete="off" />
</FormField>
<FormField
name="slug"
rules={{
required: formatMessage(labels.required),
}}
style={{ display: 'none' }}
>
<input type="hidden" />
</FormField>
<Column>
<Label>{formatMessage(labels.link)}</Label>
<Row alignItems="center" gap>
<TextField
value={`${hostUrl}/${slug}`}
value={`${hostUrl}/${currentSlug}`}
autoComplete="off"
isReadOnly
allowCopy
style={{ width: '100%' }}
style={{ flex: 1 }}
/>
<Button
variant="quiet"
@ -132,6 +137,44 @@ export function LinkEditForm({
</Row>
</Column>
<Row
alignItems="center"
gap="2"
style={{ cursor: 'pointer', userSelect: 'none' }}
onClick={() => setShowAdvanced(!showAdvanced)}
>
<Icon size="sm">{showAdvanced ? <ChevronDown /> : <ChevronRight />}</Icon>
<Label style={{ cursor: 'pointer' }}>{formatMessage(labels.advanced)}</Label>
</Row>
{showAdvanced && (
<Column gap="3">
<FormField label={formatMessage(labels.title)} name="ogTitle">
<TextField autoComplete="off" />
</FormField>
<FormField label={formatMessage(labels.description)} name="ogDescription">
<TextField autoComplete="off" />
</FormField>
<FormField label={formatMessage(labels.imageUrl)} name="ogImageUrl">
<TextField autoComplete="off" />
</FormField>
<FormField
label={formatMessage(labels.path)}
name="slug"
rules={{
required: formatMessage(labels.required),
minLength: { value: 4, message: formatMessage(labels.tooShort) },
maxLength: { value: 100, message: formatMessage(labels.tooLong) },
}}
>
<TextField autoComplete="off" minLength={4} maxLength={100} />
</FormField>
</Column>
)}
<Row justifyContent="flex-end" paddingTop="3" gap="3">
{onClose && (
<Button isDisabled={isPending} onPress={onClose}>

View file

@ -26,7 +26,10 @@ export async function POST(request: Request, { params }: { params: Promise<{ lin
const schema = z.object({
name: z.string().optional(),
url: z.string().optional(),
slug: z.string().min(8).optional(),
slug: z.string().min(4).optional(),
ogTitle: z.string().max(500).optional(),
ogDescription: z.string().max(500).optional(),
ogImageUrl: z.union([z.string().max(500).pipe(z.url()), z.literal('')]).optional(),
});
const { auth, body, error } = await parseRequest(request, schema);
@ -36,14 +39,21 @@ export async function POST(request: Request, { params }: { params: Promise<{ lin
}
const { linkId } = await params;
const { name, url, slug } = body;
const { name, url, slug, ogTitle, ogDescription, ogImageUrl } = body;
if (!(await canUpdateLink(auth, linkId))) {
return unauthorized();
}
try {
const result = await updateLink(linkId, { name, url, slug });
const result = await updateLink(linkId, {
name,
url,
slug,
ogTitle,
ogDescription,
ogImageUrl,
});
return Response.json(result);
} catch (e: any) {

View file

@ -29,7 +29,10 @@ export async function POST(request: Request) {
const schema = z.object({
name: z.string().max(100),
url: z.string().max(500),
slug: z.string().max(100),
slug: z.string().min(4).max(100),
ogTitle: z.string().max(500).optional(),
ogDescription: z.string().max(500).optional(),
ogImageUrl: z.union([z.string().max(500).pipe(z.url()), z.literal('')]).optional(),
teamId: z.string().nullable().optional(),
id: z.uuid().nullable().optional(),
});
@ -40,7 +43,7 @@ export async function POST(request: Request) {
return error();
}
const { id, name, url, slug, teamId } = body;
const { id, name, url, slug, ogTitle, ogDescription, ogImageUrl, teamId } = body;
if ((teamId && !(await canCreateTeamWebsite(auth, teamId))) || !(await canCreateWebsite(auth))) {
return unauthorized();
@ -51,6 +54,9 @@ export async function POST(request: Request) {
name,
url,
slug,
ogTitle,
ogDescription,
ogImageUrl,
teamId,
};

View file

@ -0,0 +1,14 @@
.link {
cursor: pointer;
}
.link:hover {
text-decoration: underline;
}
.iconLink {
display: flex;
align-items: center;
flex-shrink: 0;
color: var(--base-color-10);
}

View file

@ -1,23 +1,36 @@
import { Icon, Row, Text } from '@umami/react-zen';
'use client';
import { Icon, Row, Text, useToast } from '@umami/react-zen';
import Link, { type LinkProps } from 'next/link';
import type { ReactNode } from 'react';
import { useMessages } from '@/components/hooks';
import { ExternalLink as LinkIcon } from '@/components/icons';
import styles from './ExternalLink.module.css';
export function ExternalLink({
href,
children,
...props
}: LinkProps & { href: string; children: ReactNode }) {
}: Omit<LinkProps, 'href'> & { href: string; children: ReactNode }) {
const { toast } = useToast();
const { formatMessage, labels } = useMessages();
const handleCopy = () => {
navigator.clipboard.writeText(href);
toast(formatMessage(labels.copied));
};
return (
<Row alignItems="center" overflow="hidden" gap>
<Text title={href} truncate>
<Link {...props} href={href} target="_blank">
<span onClick={handleCopy} className={styles.link}>
{children}
</Link>
</span>
</Text>
<Icon size="sm" strokeColor="muted">
<LinkIcon />
</Icon>
<Link {...props} href={href} target="_blank" className={styles.iconLink}>
<Icon>
<LinkIcon />
</Icon>
</Link>
</Row>
);
}

View file

@ -4,6 +4,8 @@ export const labels = defineMessages({
ok: { id: 'label.ok', defaultMessage: 'OK' },
unknown: { id: 'label.unknown', defaultMessage: 'Unknown' },
required: { id: 'label.required', defaultMessage: 'Required' },
tooShort: { id: 'label.too-short', defaultMessage: 'Too short' },
tooLong: { id: 'label.too-long', defaultMessage: 'Too long' },
save: { id: 'label.save', defaultMessage: 'Save' },
cancel: { id: 'label.cancel', defaultMessage: 'Cancel' },
continue: { id: 'label.continue', defaultMessage: 'Continue' },
@ -355,6 +357,7 @@ export const labels = defineMessages({
saveCohort: { id: 'label.save-cohort', defaultMessage: 'Save as cohort' },
analysis: { id: 'label.analysis', defaultMessage: 'Analysis' },
destinationUrl: { id: 'label.destination-url', defaultMessage: 'Destination URL' },
imageUrl: { id: 'label.image-url', defaultMessage: 'Image URL' },
audience: { id: 'label.audience', defaultMessage: 'Audience' },
invalidUrl: { id: 'label.invalid-url', defaultMessage: 'Invalid URL' },
environment: { id: 'label.environment', defaultMessage: 'Environment' },
@ -363,6 +366,8 @@ export const labels = defineMessages({
support: { id: 'label.support', defaultMessage: 'Support' },
documentation: { id: 'label.documentation', defaultMessage: 'Documentation' },
switchAccount: { id: 'label.switch-account', defaultMessage: 'Switch account' },
advanced: { id: 'label.advanced', defaultMessage: 'Advanced' },
copied: { id: 'label.copied', defaultMessage: 'Copied to clipboard' },
});
export const messages = defineMessages({