mirror of
https://github.com/umami-software/umami.git
synced 2026-02-04 12:47:13 +01:00
Merge c3332552d7 into 860e6390f1
This commit is contained in:
commit
d18c069fe5
9 changed files with 212 additions and 53 deletions
|
|
@ -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);
|
||||
|
|
@ -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])
|
||||
|
|
|
|||
|
|
@ -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, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
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: {
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
||||
|
|
|
|||
14
src/components/common/ExternalLink.module.css
Normal file
14
src/components/common/ExternalLink.module.css
Normal 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);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue