From 6b8b29ed29b5e2e0ceac33abf6b58739fdd0ac6e Mon Sep 17 00:00:00 2001 From: diced Date: Fri, 31 Jan 2025 13:12:18 -0800 Subject: [PATCH] feat: enable/disable urls on the fly --- .../20250131204021_url_enabled/migration.sql | 2 + prisma/schema.prisma | 1 + src/components/pages/urls/EditUrlModal.tsx | 12 ++++- src/components/pages/urls/UrlCard.tsx | 21 ++++++--- src/components/pages/urls/index.tsx | 47 +++++++++++++++---- .../pages/urls/views/UrlTableView.tsx | 9 ++-- src/pages/view/url/[id].tsx | 2 + src/server/routes/api/user/urls/[id]/index.ts | 3 +- src/server/routes/api/user/urls/index.ts | 37 ++++++++------- src/server/routes/urls.dy.ts | 1 + 10 files changed, 95 insertions(+), 40 deletions(-) create mode 100644 prisma/migrations/20250131204021_url_enabled/migration.sql diff --git a/prisma/migrations/20250131204021_url_enabled/migration.sql b/prisma/migrations/20250131204021_url_enabled/migration.sql new file mode 100644 index 00000000..e5df307c --- /dev/null +++ b/prisma/migrations/20250131204021_url_enabled/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Url" ADD COLUMN "enabled" BOOLEAN NOT NULL DEFAULT true; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index c88355a3..2db06221 100755 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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? diff --git a/src/components/pages/urls/EditUrlModal.tsx b/src/components/pages/urls/EditUrlModal.tsx index c9f6e6ec..2d3271a2 100644 --- a/src/components/pages/urls/EditUrlModal.tsx +++ b/src/components/pages/urls/EditUrlModal.tsx @@ -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(''); const [vanity, setVanity] = useState(url?.vanity ?? null); const [destination, setDestination] = useState(url?.destination ?? null); + const [enabled, setEnabled] = useState(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({ } /> + setEnabled(event.currentTarget.checked)} + /> + {url.password ? ( diff --git a/src/components/pages/urls/UrlCard.tsx b/src/components/pages/urls/UrlCard.tsx index 382bdf1e..b154708a 100755 --- a/src/components/pages/urls/UrlCard.tsx +++ b/src/components/pages/urls/UrlCard.tsx @@ -20,13 +20,17 @@ export default function UserCard({ url, setSelectedUrl }: { url: Url; setSelecte - - {url.vanity ?? url.code} - + {url.enabled ? ( + + {url.vanity ?? url.code} + + ) : ( + {url.vanity ?? url.code} + )} @@ -71,6 +75,9 @@ export default function UserCard({ url, setSelectedUrl }: { url: Url; setSelecte Views: {url.views.toLocaleString()} + + Enabled: {url.enabled ? 'Yes' : 'No'} + Created: diff --git a/src/components/pages/urls/index.tsx b/src/components/pages/urls/index.tsx index 44ec315d..de356345 100755 --- a/src/components/pages/urls/index.tsx +++ b/src/components/pages/urls/index.tsx @@ -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>( + const { data, error } = await fetchApi< + Extract< + Response['/api/user/urls'], + { + url: string; + } & Omit + > + >( '/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: ( - - {data?.url} - + {data?.enabled ? ( + + {data?.url} + + ) : ( + {data?.url} + )} - - open()} variant='filled'> - - - + {data?.enabled && ( + + open()} variant='filled'> + + + + )} copy()} variant='filled'> @@ -141,6 +162,12 @@ export default function DashboardURLs() { {...form.getInputProps('maxViews')} /> + + , }, { - accessor: 'similarity', - title: 'Relevance', + accessor: 'enabled', + title: 'Enabled', sortable: true, - render: (url) => (url.similarity ? url.similarity.toFixed(4) : 'N/A'), - hidden: !searching, + render: (url) => , }, { accessor: 'actions', diff --git a/src/pages/view/url/[id].tsx b/src/pages/view/url/[id].tsx index 56f6b9f3..db81b28d 100755 --- a/src/pages/view/url/[id].tsx +++ b/src/pages/view/url/[id].tsx @@ -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({ diff --git a/src/server/routes/api/user/urls/[id]/index.ts b/src/server/routes/api/user/urls/[id]/index.ts index 84dbde16..a4f4cfa8 100755 --- a/src/server/routes/api/user/urls/[id]/index.ts +++ b/src/server/routes/api/user/urls/[id]/index.ts @@ -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: { diff --git a/src/server/routes/api/user/urls/index.ts b/src/server/routes/api/user/urls/index.ts index 76b88f30..c77dafea 100755 --- a/src/server/routes/api/user/urls/index.ts +++ b/src/server/routes/api/user/urls/index.ts @@ -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); 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); diff --git a/src/server/routes/urls.dy.ts b/src/server/routes/urls.dy.ts index 735bd0a4..9392990f 100755 --- a/src/server/routes/urls.dy.ts +++ b/src/server/routes/urls.dy.ts @@ -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) {