feat: enable/disable urls on the fly

This commit is contained in:
diced
2025-01-31 13:12:18 -08:00
parent 76d0c0786a
commit 6b8b29ed29
10 changed files with 95 additions and 40 deletions

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Url" ADD COLUMN "enabled" BOOLEAN NOT NULL DEFAULT true;

View File

@@ -336,6 +336,7 @@ model Url {
views Int @default(0)
maxViews Int?
password String?
enabled Boolean @default(true)
User User? @relation(fields: [userId], references: [id], onDelete: SetNull, onUpdate: Cascade)
userId String?

View File

@@ -1,6 +1,6 @@
import { Url } from '@/lib/db/models/url';
import { fetchApi } from '@/lib/fetchApi';
import { Button, Divider, Modal, NumberInput, PasswordInput, Stack, TextInput } from '@mantine/core';
import { Button, Divider, Modal, NumberInput, PasswordInput, Stack, Switch, TextInput } from '@mantine/core';
import { showNotification } from '@mantine/notifications';
import { IconEye, IconKey, IconPencil, IconPencilOff, IconTrashFilled } from '@tabler/icons-react';
import { useState } from 'react';
@@ -21,6 +21,7 @@ export default function EditUrlModal({
const [password, setPassword] = useState<string | null>('');
const [vanity, setVanity] = useState<string | null>(url?.vanity ?? null);
const [destination, setDestination] = useState<string | null>(url?.destination ?? null);
const [enabled, setEnabled] = useState<boolean>(url?.enabled ?? true);
const handleRemovePassword = async () => {
if (!url.password) return;
@@ -56,12 +57,14 @@ export default function EditUrlModal({
password?: string;
vanity?: string;
destination?: string;
enabled?: boolean;
} = {};
if (maxViews !== null) data['maxViews'] = maxViews;
if (password !== null) data['password'] = password?.trim();
if (vanity !== null && vanity !== url.vanity) data['vanity'] = vanity?.trim();
if (destination !== null && destination !== url.destination) data['destination'] = destination?.trim();
if (enabled !== url.enabled) data['enabled'] = enabled;
const { error } = await fetchApi(`/api/user/urls/${url.id}`, 'PATCH', data);
@@ -118,6 +121,13 @@ export default function EditUrlModal({
}
/>
<Switch
label='Enabled'
description='Prevent or allow this URL from being visited.'
checked={enabled}
onChange={(event) => setEnabled(event.currentTarget.checked)}
/>
<Divider />
{url.password ? (

View File

@@ -20,13 +20,17 @@ export default function UserCard({ url, setSelectedUrl }: { url: Url; setSelecte
<Card.Section withBorder inheritPadding py='xs'>
<Group justify='space-between'>
<Text fw={400}>
<Anchor
href={formatRootUrl(config.urls.route, url.vanity ?? url.code)}
target='_blank'
rel='noopener noreferrer'
>
{url.vanity ?? url.code}
</Anchor>
{url.enabled ? (
<Anchor
href={formatRootUrl(config.urls.route, url.vanity ?? url.code)}
target='_blank'
rel='noopener noreferrer'
>
{url.vanity ?? url.code}
</Anchor>
) : (
<Text>{url.vanity ?? url.code}</Text>
)}
</Text>
<Menu withinPortal position='bottom-end' shadow='sm'>
@@ -71,6 +75,9 @@ export default function UserCard({ url, setSelectedUrl }: { url: Url; setSelecte
<Text size='xs' c='dimmed'>
<b>Views:</b> {url.views.toLocaleString()}
</Text>
<Text size='xs' c='dimmed'>
<b>Enabled:</b> {url.enabled ? 'Yes' : 'No'}
</Text>
<Text size='xs' c='dimmed'>
<b>Created:</b> <RelativeDate date={url.createdAt} />
</Text>

View File

@@ -11,6 +11,8 @@ import {
NumberInput,
PasswordInput,
Stack,
Switch,
Text,
TextInput,
Title,
Tooltip,
@@ -25,6 +27,7 @@ import { useState } from 'react';
import { mutate } from 'swr';
import UrlGridView from './views/UrlGridView';
import UrlTableView from './views/UrlTableView';
import { Url } from '@/lib/db/models/url';
export default function DashboardURLs() {
const clipboard = useClipboard();
@@ -37,12 +40,14 @@ export default function DashboardURLs() {
vanity: string;
maxViews: '' | number;
password: string;
enabled: boolean;
}>({
initialValues: {
url: '',
vanity: '',
maxViews: '',
password: '',
enabled: true,
},
validate: {
url: (value) => (value.length < 1 ? 'URL is required' : null),
@@ -52,12 +57,20 @@ export default function DashboardURLs() {
const onSubmit = async (values: typeof form.values) => {
if (URL.canParse(values.url) === false) return form.setFieldError('url', 'Invalid URL');
const { data, error } = await fetchApi<Extract<Response['/api/user/urls'], { url: string }>>(
const { data, error } = await fetchApi<
Extract<
Response['/api/user/urls'],
{
url: string;
} & Omit<Url, 'password'>
>
>(
'/api/user/urls',
'POST',
{
destination: values.url,
vanity: values.vanity.trim() || null,
enabled: values.enabled ?? true,
},
{
...(values.maxViews !== '' && { 'x-zipline-max-views': String(values.maxViews) }),
@@ -75,8 +88,10 @@ export default function DashboardURLs() {
} else {
setOpen(false);
const open = () => window.open(data?.url, '_blank');
const open = () => (values.enabled ? window.open(data?.url, '_blank') : null);
const copy = () => {
if (!values.enabled) return;
clipboard.copy(data?.url);
notifications.show({
title: 'Copied URL to clipboard',
@@ -96,16 +111,22 @@ export default function DashboardURLs() {
children: (
<Group justify='space-between'>
<Group justify='left'>
<Anchor component={Link} href={data?.url ?? ''}>
{data?.url}
</Anchor>
{data?.enabled ? (
<Anchor component={Link} href={data?.url ?? ''}>
{data?.url}
</Anchor>
) : (
<Text>{data?.url}</Text>
)}
</Group>
<Group justify='right'>
<Tooltip label='Open link in a new tab'>
<ActionIcon onClick={() => open()} variant='filled'>
<IconExternalLink size='1rem' />
</ActionIcon>
</Tooltip>
{data?.enabled && (
<Tooltip label='Open link in a new tab'>
<ActionIcon onClick={() => open()} variant='filled'>
<IconExternalLink size='1rem' />
</ActionIcon>
</Tooltip>
)}
<Tooltip label='Copy link to clipboard'>
<ActionIcon onClick={() => copy()} variant='filled'>
<IconClipboardCopy size='1rem' />
@@ -141,6 +162,12 @@ export default function DashboardURLs() {
{...form.getInputProps('maxViews')}
/>
<Switch
label='Enabled'
description='Allow or prevent this URL from being visited'
{...form.getInputProps('enabled', { type: 'checkbox' })}
/>
<PasswordInput
label='Password'
description='Protect your link with a password'

View File

@@ -1,7 +1,7 @@
import RelativeDate from '@/components/RelativeDate';
import { Response } from '@/lib/api/response';
import { Url } from '@/lib/db/models/url';
import { ActionIcon, Anchor, Box, Group, TextInput, Tooltip } from '@mantine/core';
import { ActionIcon, Anchor, Box, Checkbox, Group, TextInput, Tooltip } from '@mantine/core';
import { DataTable, DataTableSortStatus } from 'mantine-datatable';
import { useEffect, useReducer, useState } from 'react';
import useSWR from 'swr';
@@ -247,11 +247,10 @@ export default function UrlTableView() {
render: (url) => <RelativeDate date={url.createdAt} />,
},
{
accessor: 'similarity',
title: 'Relevance',
accessor: 'enabled',
title: 'Enabled',
sortable: true,
render: (url) => (url.similarity ? url.similarity.toFixed(4) : 'N/A'),
hidden: !searching,
render: (url) => <Checkbox checked={url.enabled} />,
},
{
accessor: 'actions',

View File

@@ -75,10 +75,12 @@ export const getServerSideProps: GetServerSideProps<{
destination: true,
maxViews: true,
views: true,
enabled: true,
},
});
if (!url) return { notFound: true };
if (!url.enabled) return { notFound: true };
if (url.maxViews && url.views >= url.maxViews) {
if (config.features.deleteOnMaxViews)
await prisma.url.delete({

View File

@@ -82,6 +82,7 @@ export default fastifyPlugin(
...(req.body.password !== undefined && { password }),
...(req.body.maxViews !== undefined && { maxViews: req.body.maxViews }),
...(req.body.destination !== undefined && { destination: req.body.destination }),
...(req.body.enabled !== undefined && { enabled: req.body.enabled }),
},
omit: {
password: true,
@@ -102,11 +103,11 @@ export default fastifyPlugin(
const url = await prisma.url.findFirst({
where: {
id: id,
userId: req.user.id,
},
});
if (!url) return res.notFound();
if (url.userId !== req.user.id) return res.forbidden("You don't own this URL");
const deletedUrl = await prisma.url.delete({
where: {

View File

@@ -4,20 +4,20 @@ import { prisma } from '@/lib/db';
import { Url } from '@/lib/db/models/url';
import { log } from '@/lib/logger';
import { z } from 'zod';
import { Prisma } from '@prisma/client';
import { onShorten } from '@/lib/webhooks';
import fastifyPlugin from 'fastify-plugin';
import { userMiddleware } from '@/server/middleware/user';
export type ApiUserUrlsResponse =
| Url[]
| {
| ({
url: string;
};
} & Omit<Url, 'password'>);
type Body = {
vanity?: string;
destination: string;
enabled?: boolean;
};
type Headers = {
@@ -50,7 +50,7 @@ export default fastifyPlugin(
PATH,
{ preHandler: [userMiddleware, rateLimit] },
async (req, res) => {
const { vanity, destination } = req.body;
const { vanity, destination, enabled } = req.body;
const noJson = !!req.headers['x-zipline-no-json'];
const countUrls = await prisma.url.count({
@@ -97,6 +97,7 @@ export default fastifyPlugin(
...(vanity && { vanity: vanity }),
...(maxViews && { maxViews: maxViews }),
...(password && { password: password }),
...(enabled !== undefined && { enabled: enabled }),
},
omit: {
password: true,
@@ -133,6 +134,7 @@ export default fastifyPlugin(
if (noJson) return res.type('text/plain').send(responseUrl);
return res.send({
...url,
url: responseUrl,
});
},
@@ -149,18 +151,18 @@ export default fastifyPlugin(
if (!searchThreshold.success) return res.badRequest('Invalid searchThreshold value');
if (searchQuery) {
const similarityResult: Url[] = await prisma.$queryRaw`
SELECT
word_similarity("${Prisma.raw(searchField.data)}", ${searchQuery}) AS similarity,
*
FROM "Url"
WHERE
word_similarity("${Prisma.raw(searchField.data)}", ${searchQuery}) > ${Prisma.raw(
String(searchThreshold.data),
)} OR
"${Prisma.raw(searchField.data)}" ILIKE '${Prisma.sql`%${searchQuery}%`}' AND
"userId" = ${req.user.id};
`;
const similarityResult = await prisma.url.findMany({
where: {
[searchField.data]: {
mode: 'insensitive',
contains: searchQuery,
},
userId: req.user.id,
},
omit: {
password: true,
},
});
return res.send(similarityResult);
}
@@ -169,6 +171,9 @@ export default fastifyPlugin(
where: {
userId: req.user.id,
},
omit: {
password: true,
},
});
return res.send(urls);

View File

@@ -30,6 +30,7 @@ export async function urlsRoute(
},
});
if (!url) return req.server.nextServer.render404(req.raw, res.raw, parsedUrl);
if (!url.enabled) return req.server.nextServer.render404(req.raw, res.raw, parsedUrl);
if (url.maxViews && url.views >= url.maxViews) {
if (config.features.deleteOnMaxViews) {