diff --git a/package.json b/package.json index 788166f6..545283d6 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,8 @@ "start": "node ./build/server.mjs", "lint": "eslint --cache --ignore-path .gitignore --fix .", "format": "prettier --write --ignore-path .gitignore .", - "validate": "run-p lint format" + "validate": "run-p lint format", + "db:prototype": "prisma db push && prisma generate" }, "dependencies": { "@emotion/react": "^11.11.1", diff --git a/prisma/schema.prisma b/prisma/schema.prisma index c7c0115c..f1e8a52e 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -108,11 +108,10 @@ model File { size Int type String views Int @default(0) + maxViews Int? favorite Boolean @default(false) password String? - zeroWidthSpace String? - tags Tag[] User User? @relation(fields: [userId], references: [id], onDelete: SetNull, onUpdate: Cascade) @@ -173,15 +172,16 @@ model Url { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt + code String vanity String? destination String - name String @unique views Int @default(0) - - zeroWidthSpace String? + maxViews Int? User User? @relation(fields: [userId], references: [id], onDelete: SetNull, onUpdate: Cascade) userId String? + + @@unique([code, vanity]) } model Metric { diff --git a/src/components/file/actions.tsx b/src/components/file/actions.tsx index fdf244d7..dac49eac 100644 --- a/src/components/file/actions.tsx +++ b/src/components/file/actions.tsx @@ -23,13 +23,15 @@ export function copyFile( ) { const domain = `${window.location.protocol}//${window.location.host}`; - clipboard.copy(`${domain}/view/${file.name}`); + const url = file.url ? `${domain}${file.url}` : `${domain}/view/${file.name}`; + + clipboard.copy(url); notifications.show({ title: 'Copied link', message: ( - - {`${domain}/view/${file.name}`} + + {url} ), color: 'green', diff --git a/src/components/pages/urls/UrlCard.tsx b/src/components/pages/urls/UrlCard.tsx new file mode 100644 index 00000000..0050948f --- /dev/null +++ b/src/components/pages/urls/UrlCard.tsx @@ -0,0 +1,75 @@ +import { useConfig } from '@/components/ConfigProvider'; +import RelativeDate from '@/components/RelativeDate'; +import { Url } from '@/lib/db/models/url'; +import { formatRootUrl } from '@/lib/url'; +import { ActionIcon, Anchor, Card, Group, Menu, Stack, Text } from '@mantine/core'; +import { IconCopy, IconDots, IconTrashFilled } from '@tabler/icons-react'; +import { useState } from 'react'; +import { copyUrl, deleteUrl } from './actions'; +import { useClipboard } from '@mantine/hooks'; + +export default function UserCard({ url }: { url: Url }) { + const config = useConfig(); + const clipboard = useClipboard(); + + return ( + <> + + + + + + {url.vanity ?? url.code} + + + + + + + + + + + + + + } onClick={() => copyUrl(url, config, clipboard)}> + Copy + + } color='red' onClick={() => deleteUrl(url)}> + Delete + + + + + + + + + + Created: + + + Updated: + + + Destination:{' '} + + {url.destination} + + + {url.vanity && ( + + Code: {url.code} + + )} + + + + + ); +} diff --git a/src/components/pages/urls/actions.tsx b/src/components/pages/urls/actions.tsx new file mode 100644 index 00000000..cf7d359c --- /dev/null +++ b/src/components/pages/urls/actions.tsx @@ -0,0 +1,69 @@ +import { Response } from '@/lib/api/response'; +import type { SafeConfig } from '@/lib/config/safe'; +import { Url } from '@/lib/db/models/url'; +import { fetchApi } from '@/lib/fetchApi'; +import { formatRootUrl } from '@/lib/url'; +import { Anchor, Title } from '@mantine/core'; +import { useClipboard } from '@mantine/hooks'; +import { modals } from '@mantine/modals'; +import { notifications } from '@mantine/notifications'; +import { IconCheck, IconCopy, IconLinkOff } from '@tabler/icons-react'; +import Link from 'next/link'; +import { mutate } from 'swr'; + +export async function deleteUrl(url: Url) { + modals.openConfirmModal({ + centered: true, + title: Delete {url.code ?? url.vanity}?, + children: `Are you sure you want to delete ${url.code ?? url.vanity}? This action cannot be undone.`, + labels: { + cancel: 'Cancel', + confirm: 'Delete', + }, + confirmProps: { color: 'red' }, + onConfirm: () => handleDeleteUrl(url), + onCancel: modals.closeAll, + }); +} + +export function copyUrl(url: Url, config: SafeConfig, clipboard: ReturnType) { + const domain = `${window.location.protocol}//${window.location.host}`; + + clipboard.copy(`${domain}${formatRootUrl(config.urls.route, url.vanity ?? url.code)}`); + + notifications.show({ + title: 'Copied link', + message: ( + + {`${domain}${formatRootUrl(config.urls.route, url.vanity ?? url.code)}`} + + ), + color: 'green', + icon: , + }); +} + +async function handleDeleteUrl(url: Url) { + const { data, error } = await fetchApi( + `/api/user/urls/${url.id}`, + 'DELETE' + ); + + if (error) { + notifications.show({ + title: 'Failed to delete url', + message: error.message, + color: 'red', + icon: , + }); + } else { + notifications.show({ + title: 'Url deleted', + message: `Url ${data?.code ?? data?.vanity} has been deleted`, + color: 'green', + icon: , + }); + } + + mutate('/api/user/urls'); +} diff --git a/src/components/pages/urls/index.tsx b/src/components/pages/urls/index.tsx new file mode 100644 index 00000000..03861eab --- /dev/null +++ b/src/components/pages/urls/index.tsx @@ -0,0 +1,170 @@ +import GridTableSwitcher from '@/components/GridTableSwitcher'; +import { Response } from '@/lib/api/response'; +import { fetchApi } from '@/lib/fetchApi'; +import { useViewStore } from '@/lib/store/view'; +import { + ActionIcon, + Anchor, + Button, + Group, + Modal, + NumberInput, + Stack, + TextInput, + Title, + Tooltip, +} from '@mantine/core'; +import { hasLength, useForm } from '@mantine/form'; +import { useClipboard } from '@mantine/hooks'; +import { modals } from '@mantine/modals'; +import { notifications } from '@mantine/notifications'; +import { IconClipboardCopy, IconExternalLink, IconLink, IconLinkOff } from '@tabler/icons-react'; +import Link from 'next/link'; +import { useState } from 'react'; +import { mutate } from 'swr'; +import UrlGridView from './views/UrlGridView'; +import UrlTableView from './views/UrlTableView'; + +export default function DashboardURLs() { + const clipboard = useClipboard(); + const view = useViewStore((state) => state.urls); + + const [open, setOpen] = useState(false); + + const form = useForm<{ + url: string; + vanity: string; + maxViews: '' | number; + }>({ + initialValues: { + url: '', + vanity: '', + maxViews: '', + }, + validate: { + url: hasLength({ min: 1 }, 'URL is required'), + }, + }); + + const onSubmit = async (values: typeof form.values) => { + try { + new URL(values.url); + } catch { + return form.setFieldError('url', 'Invalid URL'); + } + + const { data, error } = await fetchApi>( + `/api/user/urls`, + 'POST', + { + destination: values.url, + vanity: values.vanity.trim() || null, + }, + values.maxViews !== '' ? { 'x-zipline-max-views': String(values.maxViews) } : {} + ); + + if (error) { + notifications.show({ + title: 'Failed to shorten URL', + message: error.message, + color: 'red', + icon: , + }); + } else { + setOpen(false); + + const open = () => window.open(data?.url, '_blank'); + const copy = () => { + clipboard.copy(data?.url); + notifications.show({ + title: 'Copied URL to clipboard', + message: ( + + {data?.url} + + ), + color: 'blue', + icon: , + }); + }; + + modals.open({ + title: Shortened URL, + size: 'auto', + children: ( + + + + {data?.url} + + + + + open()} variant='filled' color='primary'> + + + + + copy()} variant='filled' color='primary'> + + + + + + ), + }); + + mutate('/api/user/urls'); + form.reset(); + } + }; + + return ( + <> + setOpen(false)} title={Shorten a URL}> +
+ + + + + + + + +
+
+ + + URLs + + + setOpen(true)}> + + + + + + + + {view === 'grid' ? : } + + ); +} diff --git a/src/components/pages/urls/views/UrlGridView.tsx b/src/components/pages/urls/views/UrlGridView.tsx new file mode 100644 index 00000000..d002a478 --- /dev/null +++ b/src/components/pages/urls/views/UrlGridView.tsx @@ -0,0 +1,49 @@ +import { Response } from '@/lib/api/response'; +import type { Url } from '@/lib/db/models/url'; +import { Center, Group, LoadingOverlay, Paper, SimpleGrid, Stack, Text, Title } from '@mantine/core'; +import { IconLink } from '@tabler/icons-react'; +import useSWR from 'swr'; +import UrlCard from '../UrlCard'; + +export default function UrlGridView() { + const { data: urls, isLoading } = useSWR>('/api/user/urls'); + + return ( + <> + {isLoading ? ( + + + + ) : urls?.length ?? 0 !== 0 ? ( + + {urls?.map((url) => ( + + ))} + + ) : ( + +
+ + + + No URLs found + + + Shorten a URL to see them here + + +
+
+ )} + + ); +} diff --git a/src/components/pages/urls/views/UrlTableView.tsx b/src/components/pages/urls/views/UrlTableView.tsx new file mode 100644 index 00000000..274bf6c1 --- /dev/null +++ b/src/components/pages/urls/views/UrlTableView.tsx @@ -0,0 +1,121 @@ +import RelativeDate from '@/components/RelativeDate'; +import { Response } from '@/lib/api/response'; +import { Url } from '@/lib/db/models/url'; +import { ActionIcon, Anchor, Box, Group, Tooltip } from '@mantine/core'; +import { DataTable, DataTableSortStatus } from 'mantine-datatable'; +import { useEffect, useState } from 'react'; +import useSWR from 'swr'; +import { copyUrl, deleteUrl } from '../actions'; +import { IconCopy, IconTrashFilled } from '@tabler/icons-react'; +import { useConfig } from '@/components/ConfigProvider'; +import { useClipboard } from '@mantine/hooks'; + +export default function UrlTableView() { + const config = useConfig(); + const clipboard = useClipboard(); + + const { data, isLoading } = useSWR>('/api/user/urls'); + + const [sortStatus, setSortStatus] = useState({ + columnAccessor: 'createdAt', + direction: 'desc', + }); + const [sorted, setSorted] = useState(data ?? []); + + useEffect(() => { + if (data) { + const sorted = data.sort((a, b) => { + const cl = sortStatus.columnAccessor as keyof Url; + + return sortStatus.direction === 'asc' ? (a[cl]! > b[cl]! ? 1 : -1) : a[cl]! < b[cl]! ? 1 : -1; + }); + + setSorted(sorted); + } + }, [sortStatus]); + + useEffect(() => { + if (data) { + setSorted(data); + } + }, [data]); + + return ( + <> + + url.vanity ?? None, + }, + { + accessor: 'destination', + sortable: true, + render: (url) => ( + + {url.destination} + + ), + }, + { + accessor: 'maxViews', + sortable: true, + render: (url) => (url.maxViews ? url.maxViews : None), + }, + { + accessor: 'createdAt', + title: 'Created', + sortable: true, + render: (url) => , + }, + { + accessor: 'actions', + width: 150, + render: (url) => ( + + + { + e.stopPropagation(); + copyUrl(url, config, clipboard); + }} + > + + + + + { + e.stopPropagation(); + deleteUrl(url); + }} + > + + + + + ), + }, + ]} + fetching={isLoading} + sortStatus={sortStatus} + onSortStatusChange={(s) => setSortStatus(s)} + /> + + + ); +} diff --git a/src/components/pages/users/index.tsx b/src/components/pages/users/index.tsx index 01cf603d..f5952049 100644 --- a/src/components/pages/users/index.tsx +++ b/src/components/pages/users/index.tsx @@ -144,7 +144,7 @@ export default function DashboardUsers() {