From 4381d81710e26ed0cd5747b5a2b48433ea3a257c Mon Sep 17 00:00:00 2001 From: Taken Date: Thu, 26 Jun 2025 14:09:10 +0200 Subject: [PATCH] Updated dashbpard and added sonner --- package.json | 3 + pnpm-lock.yaml | 50 +++ src/app/(admin)/dashboard/layout.tsx | 8 +- src/app/(admin)/dashboard/list/page.tsx | 14 +- src/app/(admin)/dashboard/page.tsx | 8 +- src/app/layout.tsx | 2 + src/components/dashboard/url-form-card.tsx | 10 +- src/components/dashboard/urls-data-table.tsx | 379 +++++++++++++++++++ src/components/ui/sonner.tsx | 25 ++ src/components/ui/table.tsx | 116 ++++++ src/lib/db/urls.ts | 8 +- src/lib/drizzle/schema.ts | 3 +- src/lib/randomSlug.ts | 10 + src/lib/schema/url.ts | 4 +- 14 files changed, 628 insertions(+), 12 deletions(-) create mode 100644 src/components/dashboard/urls-data-table.tsx create mode 100644 src/components/ui/sonner.tsx create mode 100644 src/components/ui/table.tsx create mode 100644 src/lib/randomSlug.ts diff --git a/package.json b/package.json index 6d6c6a1..d86c40b 100644 --- a/package.json +++ b/package.json @@ -25,16 +25,19 @@ "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tooltip": "^1.2.7", "@t3-oss/env-nextjs": "^0.13.8", + "@tanstack/react-table": "^8.21.3", "better-auth": "^1.2.10", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "drizzle-orm": "^0.44.2", "lucide-react": "^0.523.0", "next": "15.3.4", + "next-themes": "^0.4.6", "postgres": "^3.4.7", "react": "^19.0.0", "react-dom": "^19.0.0", "react-hook-form": "^7.58.1", + "sonner": "^2.0.5", "tailwind-merge": "^3.3.1", "zod": "^3.25.67" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 28bbe03..fd4e85d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -35,6 +35,9 @@ importers: '@t3-oss/env-nextjs': specifier: ^0.13.8 version: 0.13.8(typescript@5.8.3)(zod@3.25.67) + '@tanstack/react-table': + specifier: ^8.21.3 + version: 8.21.3(react-dom@19.1.0(react@19.1.0))(react@19.1.0) better-auth: specifier: ^1.2.10 version: 1.2.10 @@ -53,6 +56,9 @@ importers: next: specifier: 15.3.4 version: 15.3.4(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + next-themes: + specifier: ^0.4.6 + version: 0.4.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0) postgres: specifier: ^3.4.7 version: 3.4.7 @@ -65,6 +71,9 @@ importers: react-hook-form: specifier: ^7.58.1 version: 7.58.1(react@19.1.0) + sonner: + specifier: ^2.0.5 + version: 2.0.5(react-dom@19.1.0(react@19.1.0))(react@19.1.0) tailwind-merge: specifier: ^3.3.1 version: 3.3.1 @@ -1242,6 +1251,17 @@ packages: '@tailwindcss/postcss@4.1.10': resolution: {integrity: sha512-B+7r7ABZbkXJwpvt2VMnS6ujcDoR2OOcFaqrLIo1xbcdxje4Vf+VgJdBzNNbrAjBj/rLZ66/tlQ1knIGNLKOBQ==} + '@tanstack/react-table@8.21.3': + resolution: {integrity: sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==} + engines: {node: '>=12'} + peerDependencies: + react: '>=16.8' + react-dom: '>=16.8' + + '@tanstack/table-core@8.21.3': + resolution: {integrity: sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==} + engines: {node: '>=12'} + '@tybys/wasm-util@0.9.0': resolution: {integrity: sha512-6+7nlbMVX/PVDCwaIQ8nTOPveOcFLSt8GcXdx8hD0bt39uWxYT88uXzqTd4fTvqta7oeUJqudepapKNt2DYJFw==} @@ -2376,6 +2396,12 @@ packages: natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + next-themes@0.4.6: + resolution: {integrity: sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==} + peerDependencies: + react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc + react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc + next@15.3.4: resolution: {integrity: sha512-mHKd50C+mCjam/gcnwqL1T1vPx/XQNFlXqFIVdgQdVAFY9iIQtY0IfaVflEYzKiqjeA7B0cYYMaCrmAYFjs4rA==} engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0} @@ -2660,6 +2686,12 @@ packages: simple-swizzle@0.2.2: resolution: {integrity: sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==} + sonner@2.0.5: + resolution: {integrity: sha512-YwbHQO6cSso3HBXlbCkgrgzDNIhws14r4MO87Ofy+cV2X7ES4pOoAK3+veSmVTvqNx1BWUxlhPmZzP00Crk2aQ==} + peerDependencies: + react: ^18.0.0 || ^19.0.0 || ^19.0.0-rc + react-dom: ^18.0.0 || ^19.0.0 || ^19.0.0-rc + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -3772,6 +3804,14 @@ snapshots: postcss: 8.5.6 tailwindcss: 4.1.10 + '@tanstack/react-table@8.21.3(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@tanstack/table-core': 8.21.3 + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + + '@tanstack/table-core@8.21.3': {} + '@tybys/wasm-util@0.9.0': dependencies: tslib: 2.8.1 @@ -5030,6 +5070,11 @@ snapshots: natural-compare@1.4.0: {} + next-themes@0.4.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0): + dependencies: + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + next@15.3.4(react-dom@19.1.0(react@19.1.0))(react@19.1.0): dependencies: '@next/env': 15.3.4 @@ -5371,6 +5416,11 @@ snapshots: is-arrayish: 0.3.2 optional: true + sonner@2.0.5(react-dom@19.1.0(react@19.1.0))(react@19.1.0): + dependencies: + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + source-map-js@1.2.1: {} source-map-support@0.5.21: diff --git a/src/app/(admin)/dashboard/layout.tsx b/src/app/(admin)/dashboard/layout.tsx index 5801e3a..d7860b0 100644 --- a/src/app/(admin)/dashboard/layout.tsx +++ b/src/app/(admin)/dashboard/layout.tsx @@ -9,8 +9,12 @@ export default function DashboardLayout({ }) { return ( - - {children} +
+ +
+ {children} +
+
) } diff --git a/src/app/(admin)/dashboard/list/page.tsx b/src/app/(admin)/dashboard/list/page.tsx index 4773c19..deec8ae 100644 --- a/src/app/(admin)/dashboard/list/page.tsx +++ b/src/app/(admin)/dashboard/list/page.tsx @@ -1,8 +1,16 @@ -export default function DashboardListPage() { +import { UrlsDataTable } from "@/components/dashboard/urls-data-table" +import { getAllUrls } from "@/lib/db/urls" + +export default async function DashboardListPage() { + const urls = await getAllUrls() + return (
-

List

-

This is the list page where you can view all items.

+
+

URLs

+

Manage all your shortened URLs.

+
+
) } diff --git a/src/app/(admin)/dashboard/page.tsx b/src/app/(admin)/dashboard/page.tsx index 977dc87..afc5aa1 100644 --- a/src/app/(admin)/dashboard/page.tsx +++ b/src/app/(admin)/dashboard/page.tsx @@ -15,11 +15,15 @@ export default async function Dashboard() { // Determine the most visited URL display value const mostVisitedDisplay = stats.mostVisitedUrl - ? `${stats.mostVisitedUrl.title} (${stats.mostVisitedUrl.visitCount})` + ? stats.mostVisitedUrl.visitCount > 0 + ? `${stats.mostVisitedUrl.title} (${stats.mostVisitedUrl.visitCount})` + : "No visits" : "No URLs" const mostVisitedDescription = stats.mostVisitedUrl - ? `/${stats.mostVisitedUrl.slug} - ${stats.mostVisitedUrl.visitCount} visits` + ? stats.mostVisitedUrl.visitCount > 0 + ? `/${stats.mostVisitedUrl.slug} - ${stats.mostVisitedUrl.visitCount} visits` + : "No URLs have been visited yet" : "Create your first shortened URL" return ( diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 8bce620..2c151ec 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,5 +1,6 @@ import { ReactNode } from "react" import "./globals.css" +import { Toaster } from "@/components/ui/sonner" export default function RootLayout({ children @@ -10,6 +11,7 @@ export default function RootLayout({ {children} + ) diff --git a/src/components/dashboard/url-form-card.tsx b/src/components/dashboard/url-form-card.tsx index 1e71693..c6c37ac 100644 --- a/src/components/dashboard/url-form-card.tsx +++ b/src/components/dashboard/url-form-card.tsx @@ -8,6 +8,7 @@ import { addUrl } from "@/lib/actions/url" import { urlFormSchema } from "@/lib/schema/url" import { zodResolver } from "@hookform/resolvers/zod" import { useForm } from "react-hook-form" +import { toast } from "sonner" import { z } from "zod" type UrlFormValues = z.infer @@ -17,12 +18,19 @@ export function UrlFormCard() { resolver: zodResolver(urlFormSchema), defaultValues: { url: "", - slug: "" + slug: undefined } }) async function handleSubmit(data: UrlFormValues) { const res = await addUrl(data) + + if (res.error) { + toast.error(res.message) + } else { + toast.success(res.message) + form.reset() + } } return ( diff --git a/src/components/dashboard/urls-data-table.tsx b/src/components/dashboard/urls-data-table.tsx new file mode 100644 index 0000000..4feef35 --- /dev/null +++ b/src/components/dashboard/urls-data-table.tsx @@ -0,0 +1,379 @@ +"use client" + +import { + ColumnDef, + ColumnFiltersState, + flexRender, + getCoreRowModel, + getFilteredRowModel, + getPaginationRowModel, + getSortedRowModel, + SortingState, + useReactTable, + VisibilityState +} from "@tanstack/react-table" +import { ArrowUpDown, Check, Copy, ExternalLink, MoreHorizontal, Trash2, X } from "lucide-react" +import * as React from "react" + +import { Button } from "@/components/ui/button" +import { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger +} from "@/components/ui/dropdown-menu" +import { Input } from "@/components/ui/input" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" +import { urls } from "@/lib/drizzle/schema" +import { toast } from "sonner" + +type UrlRecord = typeof urls.$inferSelect + +function handleCopy(string: string) { + navigator.clipboard.writeText(string).then(() => { + toast.success("URL copied to clipboard") + }).catch(() => { + toast.error("Failed to copy URL") + }) +} + +export const columns: ColumnDef[] = [ + { + accessorKey: "slug", + header: ({ column }) => { + return ( + + ) + }, + cell: ({ row }) =>
{row.getValue("slug")}
+ }, + { + accessorKey: "url", + header: ({ column }) => { + return ( + + ) + }, + cell: ({ row }) => { + const url = row.getValue("url") as string + return ( +
+ {url} +
+ ) + } + }, + { + accessorKey: "title", + header: "Title", + cell: ({ row }) => { + const title = row.getValue("title") as string | null + return
{title || "No title"}
+ } + }, + { + accessorKey: "maxVisits", + header: ({ column }) => { + return ( + + ) + }, + cell: ({ row }) => { + const maxVisits = row.getValue("maxVisits") as number | null + return
{maxVisits || "Unlimited"}
+ } + }, + { + accessorKey: "expDate", + header: ({ column }) => { + return ( + + ) + }, + cell: ({ row }) => { + const expDate = row.getValue("expDate") as Date | null + if (!expDate) return
Never
+ return
{expDate.toLocaleDateString()}
+ } + }, + { + accessorKey: "createdAt", + header: ({ column }) => { + return ( + + ) + }, + cell: ({ row }) => { + const createdAt = row.getValue("createdAt") as Date + return
{createdAt.toLocaleDateString()}
+ } + }, + { + accessorKey: "updatedAt", + header: ({ column }) => { + return ( + + ) + }, + cell: ({ row }) => { + const updatedAt = row.getValue("updatedAt") as Date + return
{updatedAt.toLocaleDateString()}
+ } + }, + { + accessorKey: "crawlable", + header: ({ column }) => { + return ( + + ) + }, + cell: ({ row }) => { + const crawlable = row.getValue("crawlable") as boolean + return ( +
+ {crawlable ? : } +
+ ) + } + }, + { + accessorKey: "forwardQueryParams", + header: ({ column }) => { + return ( + + ) + }, + cell: ({ row }) => { + const forwardQueryParams = row.getValue("forwardQueryParams") as boolean + return ( +
+ {forwardQueryParams ? : } +
+ ) + } + }, + { + id: "actions", + enableHiding: false, + cell: ({ row }) => { + const urlRecord = row.original + + return ( + + + + + + Actions + { + handleCopy(`${window.location.origin}/r/${urlRecord.slug}`) + }} + > + + Copy URL + + + + + Delete + + + + ) + } + } +] + +interface UrlsDataTableProps { + data: UrlRecord[] +} + +export function UrlsDataTable({ data }: UrlsDataTableProps) { + const [sorting, setSorting] = React.useState([]) + const [columnFilters, setColumnFilters] = React.useState([]) + const [columnVisibility, setColumnVisibility] = React.useState({}) + + const table = useReactTable({ + data, + columns, + onSortingChange: setSorting, + onColumnFiltersChange: setColumnFilters, + getCoreRowModel: getCoreRowModel(), + getPaginationRowModel: getPaginationRowModel(), + getSortedRowModel: getSortedRowModel(), + getFilteredRowModel: getFilteredRowModel(), + onColumnVisibilityChange: setColumnVisibility, + state: { + sorting, + columnFilters, + columnVisibility + } + }) + + return ( +
+
+ table.getColumn("slug")?.setFilterValue(event.target.value)} + className="max-w-sm" + /> + + + + + + {table + .getAllColumns() + .filter((column) => column.getCanHide()) + .map((column) => { + return ( + column.toggleVisibility(!!value)} + > + {column.id} + + ) + })} + + +
+
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + return ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext() + )} + + ) + })} + + ))} + + + {table.getRowModel().rows?.length ? + ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext() + )} + + ))} + + )) + ) : + ( + + + No results. + + + )} + +
+
+
+
+ {table.getFilteredRowModel().rows.length} row(s) total. +
+
+ + +
+
+
+ ) +} diff --git a/src/components/ui/sonner.tsx b/src/components/ui/sonner.tsx new file mode 100644 index 0000000..957524e --- /dev/null +++ b/src/components/ui/sonner.tsx @@ -0,0 +1,25 @@ +"use client" + +import { useTheme } from "next-themes" +import { Toaster as Sonner, ToasterProps } from "sonner" + +const Toaster = ({ ...props }: ToasterProps) => { + const { theme = "system" } = useTheme() + + return ( + + ) +} + +export { Toaster } diff --git a/src/components/ui/table.tsx b/src/components/ui/table.tsx new file mode 100644 index 0000000..51b74dd --- /dev/null +++ b/src/components/ui/table.tsx @@ -0,0 +1,116 @@ +"use client" + +import * as React from "react" + +import { cn } from "@/lib/utils" + +function Table({ className, ...props }: React.ComponentProps<"table">) { + return ( +
+ + + ) +} + +function TableHeader({ className, ...props }: React.ComponentProps<"thead">) { + return ( + + ) +} + +function TableBody({ className, ...props }: React.ComponentProps<"tbody">) { + return ( + + ) +} + +function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) { + return ( + tr]:last:border-b-0", + className + )} + {...props} + /> + ) +} + +function TableRow({ className, ...props }: React.ComponentProps<"tr">) { + return ( + + ) +} + +function TableHead({ className, ...props }: React.ComponentProps<"th">) { + return ( +
[role=checkbox]]:translate-y-[2px]", + className + )} + {...props} + /> + ) +} + +function TableCell({ className, ...props }: React.ComponentProps<"td">) { + return ( + [role=checkbox]]:translate-y-[2px]", + className + )} + {...props} + /> + ) +} + +function TableCaption({ + className, + ...props +}: React.ComponentProps<"caption">) { + return ( +
+ ) +} + +export { + Table, + TableHeader, + TableBody, + TableFooter, + TableHead, + TableRow, + TableCell, + TableCaption, +} diff --git a/src/lib/db/urls.ts b/src/lib/db/urls.ts index ae1f1d1..2059529 100644 --- a/src/lib/db/urls.ts +++ b/src/lib/db/urls.ts @@ -1,7 +1,13 @@ -import { eq } from "drizzle-orm"; +import { eq, desc } from "drizzle-orm"; import { db } from "../drizzle/db"; import { urls } from "../drizzle/schema"; +export function getAllUrls() { + return db.query.urls.findMany({ + orderBy: desc(urls.createdAt) + }) +} + export function insertUrl(data: typeof urls.$inferInsert) { return db.insert(urls).values(data) } diff --git a/src/lib/drizzle/schema.ts b/src/lib/drizzle/schema.ts index dc244d9..b7c50fc 100644 --- a/src/lib/drizzle/schema.ts +++ b/src/lib/drizzle/schema.ts @@ -2,6 +2,7 @@ export { account, session, user, verification } from "./auth-schema" import { relations } from "drizzle-orm" import { boolean, index, integer, pgTable, timestamp, uuid, varchar } from "drizzle-orm/pg-core" +import { generateRandomSlug } from "../randomSlug" const createdAt = timestamp("created_at", { withTimezone: true }).defaultNow() const updatedAt = timestamp("updated_at", { withTimezone: true }).defaultNow().$onUpdate(() => new Date()) @@ -9,7 +10,7 @@ const updatedAt = timestamp("updated_at", { withTimezone: true }).defaultNow().$ export const urls = pgTable("urls", { id: uuid("id").primaryKey().notNull().defaultRandom(), url: varchar("url").notNull(), - slug: varchar("slug").unique().notNull(), + slug: varchar("slug").unique().notNull().$defaultFn(() => generateRandomSlug()), title: varchar("title"), maxVisits: integer("max_visits"), expDate: timestamp("exp_date", { withTimezone: true }), diff --git a/src/lib/randomSlug.ts b/src/lib/randomSlug.ts new file mode 100644 index 0000000..b9e41ff --- /dev/null +++ b/src/lib/randomSlug.ts @@ -0,0 +1,10 @@ +export function generateRandomSlug(): string { + const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + let result = ''; + + for (let i = 0; i < 5; i++) { + result += characters.charAt(Math.floor(Math.random() * characters.length)); + } + + return result; +} \ No newline at end of file diff --git a/src/lib/schema/url.ts b/src/lib/schema/url.ts index 92e7436..f846e4d 100644 --- a/src/lib/schema/url.ts +++ b/src/lib/schema/url.ts @@ -4,10 +4,10 @@ export const urlFormSchema = z.object({ url: z.string().url("Please enter a valid URL"), slug: z .string() - .min(1, "Slug is required") + .min(1, "Slug must be at least 1 character long") .max(50, "Slug must be 50 characters or less") .regex( /^[a-zA-Z0-9-_]+$/, "Slug can only contain letters, numbers, hyphens, and underscores" - ) + ).optional(), }) \ No newline at end of file