Link editing.

This commit is contained in:
Mike Cao 2025-08-15 11:11:24 -07:00
parent 0558563d35
commit 5f4b83b09c
13 changed files with 123 additions and 89 deletions

View file

@ -7,7 +7,6 @@ import { useApi, useMessages, useModified } from '@/components/hooks';
export function LinkDeleteButton({
linkId,
websiteId,
name,
onSave,
}: {
@ -19,7 +18,7 @@ export function LinkDeleteButton({
const { formatMessage, labels } = useMessages();
const { del, useMutation } = useApi();
const { mutate, isPending, error } = useMutation({
mutationFn: () => del(`/websites/${websiteId}/links/${linkId}`),
mutationFn: () => del(`/links/${linkId}`),
});
const { touch } = useModified();

View file

@ -9,7 +9,7 @@ export function LinkEditButton({ linkId }: { linkId: string }) {
return (
<ActionButton title={formatMessage(labels.edit)} icon={<Edit />}>
<Dialog title={formatMessage(labels.link)} style={{ width: 800 }}>
<Dialog title={formatMessage(labels.link)} style={{ width: 800, minHeight: 300 }}>
{({ close }) => {
return <LinkEditForm linkId={linkId} onClose={close} />;
}}

View file

@ -1,3 +1,4 @@
import { useState, useEffect } from 'react';
import {
Form,
FormField,
@ -5,7 +6,6 @@ import {
Row,
TextField,
Button,
Text,
Label,
Column,
Icon,
@ -16,6 +16,8 @@ import { useMessages } from '@/components/hooks';
import { Refresh } from '@/components/icons';
import { getRandomChars } from '@/lib/crypto';
import { useUpdateQuery } from '@/components/hooks/queries/useUpdateQuery';
import { LINKS_URL } from '@/lib/constants';
import { isValidUrl } from '@/lib/url';
const generateId = () => getRandomChars(9);
@ -31,29 +33,55 @@ export function LinkEditForm({
onClose?: () => void;
}) {
const { formatMessage, labels } = useMessages();
const { mutate, error, isPending } = useUpdateQuery('/links', { id: linkId, teamId });
const { linkDomain } = useConfig();
const { mutate, error, isPending, touch } = 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) => {
mutate(data, {
onSuccess: async () => {
touch('links');
onSave?.();
onClose?.();
},
});
};
if (linkId && !isLoading) {
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 position="page" />;
}
return (
<Form
onSubmit={handleSubmit}
error={error?.message}
defaultValues={{ slug: generateId(), ...data }}
>
<Form onSubmit={handleSubmit} error={error?.message} defaultValues={{ slug, ...data }}>
{({ setValue }) => {
return (
<>
@ -62,33 +90,40 @@ export function LinkEditForm({
name="name"
rules={{ required: formatMessage(labels.required) }}
>
<TextField autoComplete="off" />
<TextField autoComplete="off" autoFocus />
</FormField>
<FormField
label={formatMessage(labels.destinationUrl)}
name="url"
rules={{ required: formatMessage(labels.required) }}
rules={{ required: formatMessage(labels.required), validate: checkUrl }}
>
<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>
<Text>{linkDomain || window.location.origin}/</Text>
<FormField
name="slug"
rules={{
required: formatMessage(labels.required),
}}
<TextField
value={`${hostUrl}/${slug}`}
autoComplete="off"
isReadOnly
allowCopy
style={{ width: '100%' }}
>
<TextField autoComplete="off" isReadOnly />
</FormField>
/>
<Button
variant="quiet"
onPress={() => setValue('slug', generateId(), { shouldDirty: true })}
onPress={() => setValue('slug', handleSlug(), { shouldDirty: true })}
>
<Icon>
<Refresh />

View file

@ -5,12 +5,13 @@ import { DateDistance } from '@/components/common/DateDistance';
import { ExternalLink } from '@/components/common/ExternalLink';
import { LinkEditButton } from './LinkEditButton';
import { LinkDeleteButton } from './LinkDeleteButton';
import { LINKS_URL } from '@/lib/constants';
export function LinksTable({ data = [] }) {
const { formatMessage, labels } = useMessages();
const { websiteId } = useNavigation();
const { linksUrl } = useConfig();
const hostUrl = linksUrl || `${window.location.origin}/x`;
const hostUrl = linksUrl || LINKS_URL;
if (data.length === 0) {
return <Empty />;

View file

@ -1,16 +1,17 @@
import Link from 'next/link';
import { DataTable, DataColumn, Row } from '@umami/react-zen';
import { useConfig, useMessages, useNavigation } from '@/components/hooks';
import { Empty } from '@/components/common/Empty';
import { DateDistance } from '@/components/common/DateDistance';
import { PixelEditButton } from './PixelEditButton';
import { PixelDeleteButton } from './PixelDeleteButton';
import Link from 'next/link';
import { PIXELS_URL } from '@/lib/constants';
export function PixelsTable({ data = [] }) {
const { formatMessage, labels } = useMessages();
const { websiteId } = useNavigation();
const { pixelsUrl } = useConfig();
const defaultUrl = `${window.location.origin}/p`;
const hostUrl = pixelsUrl || PIXELS_URL;
if (data.length === 0) {
return <Empty />;
@ -21,7 +22,7 @@ export function PixelsTable({ data = [] }) {
<DataColumn id="name" label={formatMessage(labels.name)} />
<DataColumn id="url" label="URL">
{({ slug }: any) => {
const url = `${pixelsUrl || defaultUrl}/${slug}`;
const url = `${hostUrl}/${slug}`;
return <Link href={url}>{url}</Link>;
}}
</DataColumn>

View file

@ -1,39 +1,32 @@
import { z } from 'zod';
import { canUpdateLink, canDeleteLink, canViewLink } from '@/validations';
import { SHARE_ID_REGEX } from '@/lib/constants';
import { parseRequest } from '@/lib/request';
import { ok, json, unauthorized, serverError } from '@/lib/response';
import { ok, json, unauthorized, serverError, badRequest } from '@/lib/response';
import { deleteLink, getLink, updateLink } from '@/queries';
export async function GET(
request: Request,
{ params }: { params: Promise<{ websiteId: string }> },
) {
export async function GET(request: Request, { params }: { params: Promise<{ linkId: string }> }) {
const { auth, error } = await parseRequest(request);
if (error) {
return error();
}
const { websiteId } = await params;
const { linkId } = await params;
if (!(await canViewLink(auth, websiteId))) {
if (!(await canViewLink(auth, linkId))) {
return unauthorized();
}
const website = await getLink(websiteId);
const website = await getLink(linkId);
return json(website);
}
export async function POST(
request: Request,
{ params }: { params: Promise<{ websiteId: string }> },
) {
export async function POST(request: Request, { params }: { params: Promise<{ linkId: string }> }) {
const schema = z.object({
name: z.string().optional(),
domain: z.string().optional(),
shareId: z.string().regex(SHARE_ID_REGEX).nullable().optional(),
name: z.string(),
url: z.string(),
slug: z.string(),
});
const { auth, body, error } = await parseRequest(request, schema);
@ -42,20 +35,20 @@ export async function POST(
return error();
}
const { websiteId } = await params;
const { name, domain, shareId } = body;
const { linkId } = await params;
const { name, url, slug } = body;
if (!(await canUpdateLink(auth, websiteId))) {
if (!(await canUpdateLink(auth, linkId))) {
return unauthorized();
}
try {
const website = await updateLink(websiteId, { name, domain, shareId });
const result = await updateLink(linkId, { name, url, slug });
return Response.json(website);
return Response.json(result);
} catch (e: any) {
if (e.message.includes('Unique constraint') && e.message.includes('share_id')) {
return serverError(new Error('That share ID is already taken.'));
if (e.message.includes('Unique constraint') && e.message.includes('slug')) {
return badRequest('That slug is already taken.');
}
return serverError(e);
@ -64,7 +57,7 @@ export async function POST(
export async function DELETE(
request: Request,
{ params }: { params: Promise<{ websiteId: string }> },
{ params }: { params: Promise<{ linkId: string }> },
) {
const { auth, error } = await parseRequest(request);
@ -72,13 +65,13 @@ export async function DELETE(
return error();
}
const { websiteId } = await params;
const { linkId } = await params;
if (!(await canDeleteLink(auth, websiteId))) {
if (!(await canDeleteLink(auth, linkId))) {
return unauthorized();
}
await deleteLink(websiteId);
await deleteLink(linkId);
return ok();
}

View file

@ -1,39 +1,32 @@
import { z } from 'zod';
import { canUpdateWebsite, canDeleteWebsite, canViewWebsite } from '@/validations';
import { SHARE_ID_REGEX } from '@/lib/constants';
import { canUpdatePixel, canDeletePixel, canViewPixel } from '@/validations';
import { parseRequest } from '@/lib/request';
import { ok, json, unauthorized, serverError } from '@/lib/response';
import { deleteWebsite, getWebsite, updateWebsite } from '@/queries';
import { ok, json, unauthorized, serverError, badRequest } from '@/lib/response';
import { deletePixel, getPixel, updatePixel } from '@/queries';
export async function GET(
request: Request,
{ params }: { params: Promise<{ websiteId: string }> },
) {
export async function GET(request: Request, { params }: { params: Promise<{ pixelId: string }> }) {
const { auth, error } = await parseRequest(request);
if (error) {
return error();
}
const { websiteId } = await params;
const { pixelId } = await params;
if (!(await canViewWebsite(auth, websiteId))) {
if (!(await canViewPixel(auth, pixelId))) {
return unauthorized();
}
const website = await getWebsite(websiteId);
const pixel = await getPixel(pixelId);
return json(website);
return json(pixel);
}
export async function POST(
request: Request,
{ params }: { params: Promise<{ websiteId: string }> },
) {
export async function POST(request: Request, { params }: { params: Promise<{ pixelId: string }> }) {
const schema = z.object({
name: z.string().optional(),
domain: z.string().optional(),
shareId: z.string().regex(SHARE_ID_REGEX).nullable().optional(),
name: z.string(),
url: z.string().url(),
slug: z.string().min(8),
});
const { auth, body, error } = await parseRequest(request, schema);
@ -42,20 +35,20 @@ export async function POST(
return error();
}
const { websiteId } = await params;
const { pixelId } = await params;
const { name, domain, shareId } = body;
if (!(await canUpdateWebsite(auth, websiteId))) {
if (!(await canUpdatePixel(auth, pixelId))) {
return unauthorized();
}
try {
const website = await updateWebsite(websiteId, { name, domain, shareId });
const pixel = await updatePixel(pixelId, { name, domain, shareId });
return Response.json(website);
return Response.json(pixel);
} catch (e: any) {
if (e.message.includes('Unique constraint') && e.message.includes('share_id')) {
return serverError(new Error('That share ID is already taken.'));
if (e.message.includes('Unique constraint') && e.message.includes('slug')) {
return badRequest('That slug is already taken.');
}
return serverError(e);
@ -64,7 +57,7 @@ export async function POST(
export async function DELETE(
request: Request,
{ params }: { params: Promise<{ websiteId: string }> },
{ params }: { params: Promise<{ pixelId: string }> },
) {
const { auth, error } = await parseRequest(request);
@ -72,13 +65,13 @@ export async function DELETE(
return error();
}
const { websiteId } = await params;
const { pixelId } = await params;
if (!(await canDeleteWebsite(auth, websiteId))) {
if (!(await canDeletePixel(auth, pixelId))) {
return unauthorized();
}
await deleteWebsite(websiteId);
await deletePixel(pixelId);
return ok();
}

View file

@ -20,9 +20,9 @@ export async function GET(request: Request) {
const filters = await getQueryFilters(query);
const inks = await getUserLinks(auth.user.id, filters);
const links = await getUserLinks(auth.user.id, filters);
return json(inks);
return json(links);
}
export async function POST(request: Request) {

View file

@ -2,7 +2,7 @@ import { z } from 'zod';
import { canUpdateWebsite, canDeleteWebsite, canViewWebsite } from '@/validations';
import { SHARE_ID_REGEX } from '@/lib/constants';
import { parseRequest } from '@/lib/request';
import { ok, json, unauthorized, serverError } from '@/lib/response';
import { ok, json, unauthorized, serverError, badRequest } from '@/lib/response';
import { deleteWebsite, getWebsite, updateWebsite } from '@/queries';
export async function GET(
@ -55,7 +55,7 @@ export async function POST(
return Response.json(website);
} catch (e: any) {
if (e.message.includes('Unique constraint') && e.message.includes('share_id')) {
return serverError(new Error('That share ID is already taken.'));
return badRequest('That share ID is already taken.');
}
return serverError(e);

View file

@ -8,7 +8,7 @@ export function useLinkQuery(linkId: string) {
return useQuery({
queryKey: ['link', { linkId, modified }],
queryFn: () => {
return get(`/link/${linkId}`);
return get(`/links/${linkId}`);
},
enabled: !!linkId,
});

View file

@ -353,6 +353,7 @@ export const labels = defineMessages({
analysis: { id: 'label.analysis', defaultMessage: 'Analysis' },
destinationUrl: { id: 'label.destination-url', defaultMessage: 'Destination URL' },
audience: { id: 'label.audience', defaultMessage: 'Audience' },
invalidUrl: { id: 'label.invalid-url', defaultMessage: 'Invalid URL' },
});
export const messages = defineMessages({

View file

@ -12,6 +12,8 @@ export const REPO_URL = 'https://github.com/umami-software/umami';
export const UPDATES_URL = 'https://api.umami.is/v1/updates';
export const TELEMETRY_PIXEL = 'https://i.umami.is/a.png';
export const FAVICON_URL = 'https://icons.duckduckgo.com/ip3/{{domain}}.ico';
export const LINKS_URL = `${globalThis?.location?.origin}/q`;
export const PIXELS_URL = `${globalThis?.location?.origin}/p`;
export const DEFAULT_LOCALE = 'en-US';
export const DEFAULT_THEME = 'light';

View file

@ -38,3 +38,12 @@ export function safeDecodeURIComponent(s: string | undefined | null): string | u
return s;
}
}
export function isValidUrl(url: string) {
try {
new URL(url);
return true;
} catch {
return false;
}
}