diff --git a/package.json b/package.json
index 15575d2..6d6c6a1 100644
--- a/package.json
+++ b/package.json
@@ -16,6 +16,7 @@
"db:down": "docker compose -f dev-db.yml down"
},
"dependencies": {
+ "@hookform/resolvers": "^5.1.1",
"@radix-ui/react-avatar": "^1.1.10",
"@radix-ui/react-dialog": "^1.1.14",
"@radix-ui/react-dropdown-menu": "^2.1.15",
@@ -33,6 +34,7 @@
"postgres": "^3.4.7",
"react": "^19.0.0",
"react-dom": "^19.0.0",
+ "react-hook-form": "^7.58.1",
"tailwind-merge": "^3.3.1",
"zod": "^3.25.67"
},
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 368cd46..28bbe03 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -8,6 +8,9 @@ importers:
.:
dependencies:
+ '@hookform/resolvers':
+ specifier: ^5.1.1
+ version: 5.1.1(react-hook-form@7.58.1(react@19.1.0))
'@radix-ui/react-avatar':
specifier: ^1.1.10
version: 1.1.10(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
@@ -59,6 +62,9 @@ importers:
react-dom:
specifier: ^19.0.0
version: 19.1.0(react@19.1.0)
+ react-hook-form:
+ specifier: ^7.58.1
+ version: 7.58.1(react@19.1.0)
tailwind-merge:
specifier: ^3.3.1
version: 3.3.1
@@ -478,6 +484,11 @@ packages:
'@hexagon/base64@1.1.28':
resolution: {integrity: sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw==}
+ '@hookform/resolvers@5.1.1':
+ resolution: {integrity: sha512-J/NVING3LMAEvexJkyTLjruSm7aOFx7QX21pzkiJfMoNG0wl5aFEjLTl7ay7IQb9EWY6AkrBy7tHL2Alijpdcg==}
+ peerDependencies:
+ react-hook-form: ^7.55.0
+
'@humanfs/core@0.19.1':
resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==}
engines: {node: '>=18.18.0'}
@@ -1100,6 +1111,9 @@ packages:
resolution: {integrity: sha512-1hsLpRHfSuMB9ee2aAdh0Htza/X3f4djhYISrggqGe3xopNjOcePiSDkDDoPzDYaaMCrbqGP1H2TYU7bgL9PmA==}
engines: {node: '>=20.0.0'}
+ '@standard-schema/utils@0.3.0':
+ resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==}
+
'@swc/counter@0.1.3':
resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==}
@@ -2499,6 +2513,12 @@ packages:
peerDependencies:
react: ^19.1.0
+ react-hook-form@7.58.1:
+ resolution: {integrity: sha512-Lml/KZYEEFfPhUVgE0RdCVpnC4yhW+PndRhbiTtdvSlQTL8IfVR+iQkBjLIvmmc6+GGoVeM11z37ktKFPAb0FA==}
+ engines: {node: '>=18.0.0'}
+ peerDependencies:
+ react: ^16.8.0 || ^17 || ^18 || ^19
+
react-is@16.13.1:
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
@@ -3105,6 +3125,11 @@ snapshots:
'@hexagon/base64@1.1.28': {}
+ '@hookform/resolvers@5.1.1(react-hook-form@7.58.1(react@19.1.0))':
+ dependencies:
+ '@standard-schema/utils': 0.3.0
+ react-hook-form: 7.58.1(react@19.1.0)
+
'@humanfs/core@0.19.1': {}
'@humanfs/node@0.16.6':
@@ -3655,6 +3680,8 @@ snapshots:
'@peculiar/asn1-schema': 2.3.15
'@peculiar/asn1-x509': 2.3.15
+ '@standard-schema/utils@0.3.0': {}
+
'@swc/counter@0.1.3': {}
'@swc/helpers@0.5.15':
@@ -5148,6 +5175,10 @@ snapshots:
react: 19.1.0
scheduler: 0.26.0
+ react-hook-form@7.58.1(react@19.1.0):
+ dependencies:
+ react: 19.1.0
+
react-is@16.13.1: {}
react-remove-scroll-bar@2.3.8(@types/react@19.1.8)(react@19.1.0):
diff --git a/src/app/(admin)/dashboard/page.tsx b/src/app/(admin)/dashboard/page.tsx
index 749c9d6..977dc87 100644
--- a/src/app/(admin)/dashboard/page.tsx
+++ b/src/app/(admin)/dashboard/page.tsx
@@ -1,4 +1,5 @@
import { StatsCard } from "@/components/dashboard/stats-card"
+import { UrlFormCard } from "@/components/dashboard/url-form-card"
import { getSession } from "@/lib/auth/session"
import { getDashboardStats } from "@/lib/dashboard/stats"
import { LinkIcon, MousePointerClick, TrendingUp } from "lucide-react"
@@ -24,8 +25,6 @@ export default async function Dashboard() {
return (
Dashboard
-
Welcome to your dashboard! Use the sidebar to navigate to different sections.
-
+
+
)
}
diff --git a/src/components/dashboard/url-form-card.tsx b/src/components/dashboard/url-form-card.tsx
new file mode 100644
index 0000000..1e71693
--- /dev/null
+++ b/src/components/dashboard/url-form-card.tsx
@@ -0,0 +1,92 @@
+"use client"
+
+import { Button } from "@/components/ui/button"
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
+import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"
+import { Input } from "@/components/ui/input"
+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 { z } from "zod"
+
+type UrlFormValues = z.infer
+
+export function UrlFormCard() {
+ const form = useForm({
+ resolver: zodResolver(urlFormSchema),
+ defaultValues: {
+ url: "",
+ slug: ""
+ }
+ })
+
+ async function handleSubmit(data: UrlFormValues) {
+ const res = await addUrl(data)
+ }
+
+ return (
+
+
+ Create Short Link
+
+ Enter a URL and create a custom slug for your short link.
+
+
+
+
+
+
+
+ )
+}
diff --git a/src/components/ui/form.tsx b/src/components/ui/form.tsx
new file mode 100644
index 0000000..524b986
--- /dev/null
+++ b/src/components/ui/form.tsx
@@ -0,0 +1,167 @@
+"use client"
+
+import * as React from "react"
+import * as LabelPrimitive from "@radix-ui/react-label"
+import { Slot } from "@radix-ui/react-slot"
+import {
+ Controller,
+ FormProvider,
+ useFormContext,
+ useFormState,
+ type ControllerProps,
+ type FieldPath,
+ type FieldValues,
+} from "react-hook-form"
+
+import { cn } from "@/lib/utils"
+import { Label } from "@/components/ui/label"
+
+const Form = FormProvider
+
+type FormFieldContextValue<
+ TFieldValues extends FieldValues = FieldValues,
+ TName extends FieldPath = FieldPath,
+> = {
+ name: TName
+}
+
+const FormFieldContext = React.createContext(
+ {} as FormFieldContextValue
+)
+
+const FormField = <
+ TFieldValues extends FieldValues = FieldValues,
+ TName extends FieldPath = FieldPath,
+>({
+ ...props
+}: ControllerProps) => {
+ return (
+
+
+
+ )
+}
+
+const useFormField = () => {
+ const fieldContext = React.useContext(FormFieldContext)
+ const itemContext = React.useContext(FormItemContext)
+ const { getFieldState } = useFormContext()
+ const formState = useFormState({ name: fieldContext.name })
+ const fieldState = getFieldState(fieldContext.name, formState)
+
+ if (!fieldContext) {
+ throw new Error("useFormField should be used within ")
+ }
+
+ const { id } = itemContext
+
+ return {
+ id,
+ name: fieldContext.name,
+ formItemId: `${id}-form-item`,
+ formDescriptionId: `${id}-form-item-description`,
+ formMessageId: `${id}-form-item-message`,
+ ...fieldState,
+ }
+}
+
+type FormItemContextValue = {
+ id: string
+}
+
+const FormItemContext = React.createContext(
+ {} as FormItemContextValue
+)
+
+function FormItem({ className, ...props }: React.ComponentProps<"div">) {
+ const id = React.useId()
+
+ return (
+
+
+
+ )
+}
+
+function FormLabel({
+ className,
+ ...props
+}: React.ComponentProps) {
+ const { error, formItemId } = useFormField()
+
+ return (
+
+ )
+}
+
+function FormControl({ ...props }: React.ComponentProps) {
+ const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
+
+ return (
+
+ )
+}
+
+function FormDescription({ className, ...props }: React.ComponentProps<"p">) {
+ const { formDescriptionId } = useFormField()
+
+ return (
+
+ )
+}
+
+function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
+ const { error, formMessageId } = useFormField()
+ const body = error ? String(error?.message ?? "") : props.children
+
+ if (!body) {
+ return null
+ }
+
+ return (
+
+ {body}
+
+ )
+}
+
+export {
+ useFormField,
+ Form,
+ FormItem,
+ FormLabel,
+ FormControl,
+ FormDescription,
+ FormMessage,
+ FormField,
+}
diff --git a/src/lib/actions/url.ts b/src/lib/actions/url.ts
new file mode 100644
index 0000000..39e8f5b
--- /dev/null
+++ b/src/lib/actions/url.ts
@@ -0,0 +1,37 @@
+"use server"
+
+import { getSession } from "../auth/session"
+import { insertUrl } from "../db/urls"
+import { urlFormSchema } from "../schema/url"
+
+type Response = {
+ error: boolean
+ message: string
+}
+
+export async function addUrl(unsafeData: unknown): Promise {
+ const { session } = await getSession()
+
+ if (!session) {
+ return {
+ error: true,
+ message: "You must be logged in to create a short link."
+ }
+ }
+
+ const { error, data } = urlFormSchema.safeParse(unsafeData)
+
+ if (error) {
+ return {
+ error: true,
+ message: "Error parsing form data."
+ }
+ }
+
+ await insertUrl(data)
+
+ return {
+ error: false,
+ message: "Short link created successfully!"
+ }
+}
\ No newline at end of file
diff --git a/src/lib/db/urls.ts b/src/lib/db/urls.ts
new file mode 100644
index 0000000..ae1f1d1
--- /dev/null
+++ b/src/lib/db/urls.ts
@@ -0,0 +1,15 @@
+import { eq } from "drizzle-orm";
+import { db } from "../drizzle/db";
+import { urls } from "../drizzle/schema";
+
+export function insertUrl(data: typeof urls.$inferInsert) {
+ return db.insert(urls).values(data)
+}
+
+export function updateUrl(id: string, data: Omit, "id">) {
+ return db.update(urls).set(data).where(eq(urls.id, id))
+}
+
+export function deleteUrl(id: string) {
+ return db.delete(urls).where(eq(urls.id, id))
+}
\ No newline at end of file
diff --git a/src/lib/drizzle/migrations/0001_flawless_goblin_queen.sql b/src/lib/drizzle/migrations/0001_flawless_goblin_queen.sql
new file mode 100644
index 0000000..e99de9d
--- /dev/null
+++ b/src/lib/drizzle/migrations/0001_flawless_goblin_queen.sql
@@ -0,0 +1,2 @@
+ALTER TABLE "urls" ALTER COLUMN "id" SET DEFAULT gen_random_uuid();--> statement-breakpoint
+ALTER TABLE "urls" ALTER COLUMN "title" DROP NOT NULL;
\ No newline at end of file
diff --git a/src/lib/drizzle/migrations/meta/0001_snapshot.json b/src/lib/drizzle/migrations/meta/0001_snapshot.json
new file mode 100644
index 0000000..6ea215b
--- /dev/null
+++ b/src/lib/drizzle/migrations/meta/0001_snapshot.json
@@ -0,0 +1,532 @@
+{
+ "id": "3be7b31f-8b1b-457a-839a-2d490f48e833",
+ "prevId": "79b6d1a7-2d90-461c-bd57-51a42fc17b23",
+ "version": "7",
+ "dialect": "postgresql",
+ "tables": {
+ "public.account": {
+ "name": "account",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "account_id": {
+ "name": "account_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "provider_id": {
+ "name": "provider_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "access_token": {
+ "name": "access_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "refresh_token": {
+ "name": "refresh_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "id_token": {
+ "name": "id_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "access_token_expires_at": {
+ "name": "access_token_expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "refresh_token_expires_at": {
+ "name": "refresh_token_expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "scope": {
+ "name": "scope",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "password": {
+ "name": "password",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "account_user_id_user_id_fk": {
+ "name": "account_user_id_user_id_fk",
+ "tableFrom": "account",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.session": {
+ "name": "session",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "token": {
+ "name": "token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "ip_address": {
+ "name": "ip_address",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "user_agent": {
+ "name": "user_agent",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "session_user_id_user_id_fk": {
+ "name": "session_user_id_user_id_fk",
+ "tableFrom": "session",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "session_token_unique": {
+ "name": "session_token_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "token"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.urls": {
+ "name": "urls",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "url": {
+ "name": "url",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "slug": {
+ "name": "slug",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "title": {
+ "name": "title",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "max_visits": {
+ "name": "max_visits",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "exp_date": {
+ "name": "exp_date",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "forward_query_params": {
+ "name": "forward_query_params",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": false,
+ "default": true
+ },
+ "crawable": {
+ "name": "crawable",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": false,
+ "default": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "urls_slug_idx": {
+ "name": "urls_slug_idx",
+ "columns": [
+ {
+ "expression": "slug",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "urls_url_idx": {
+ "name": "urls_url_idx",
+ "columns": [
+ {
+ "expression": "url",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "exp_date_idx": {
+ "name": "exp_date_idx",
+ "columns": [
+ {
+ "expression": "exp_date",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "urls_slug_unique": {
+ "name": "urls_slug_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "slug"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.user": {
+ "name": "user",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "email_verified": {
+ "name": "email_verified",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "image": {
+ "name": "image",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "user_email_unique": {
+ "name": "user_email_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "email"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.verification": {
+ "name": "verification",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "identifier": {
+ "name": "identifier",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "value": {
+ "name": "value",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.visits": {
+ "name": "visits",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "url_id": {
+ "name": "url_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "ip_address": {
+ "name": "ip_address",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_agent": {
+ "name": "user_agent",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "visits_url_id_idx": {
+ "name": "visits_url_id_idx",
+ "columns": [
+ {
+ "expression": "url_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "visits_url_id_urls_id_fk": {
+ "name": "visits_url_id_urls_id_fk",
+ "tableFrom": "visits",
+ "tableTo": "urls",
+ "columnsFrom": [
+ "url_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ }
+ },
+ "enums": {},
+ "schemas": {},
+ "sequences": {},
+ "roles": {},
+ "policies": {},
+ "views": {},
+ "_meta": {
+ "columns": {},
+ "schemas": {},
+ "tables": {}
+ }
+}
\ No newline at end of file
diff --git a/src/lib/drizzle/migrations/meta/_journal.json b/src/lib/drizzle/migrations/meta/_journal.json
index 969b92b..b825d43 100644
--- a/src/lib/drizzle/migrations/meta/_journal.json
+++ b/src/lib/drizzle/migrations/meta/_journal.json
@@ -8,6 +8,13 @@
"when": 1750934261568,
"tag": "0000_cultured_colonel_america",
"breakpoints": true
+ },
+ {
+ "idx": 1,
+ "version": "7",
+ "when": 1750938276218,
+ "tag": "0001_flawless_goblin_queen",
+ "breakpoints": true
}
]
}
\ No newline at end of file
diff --git a/src/lib/drizzle/schema.ts b/src/lib/drizzle/schema.ts
index 442bd8c..dc244d9 100644
--- a/src/lib/drizzle/schema.ts
+++ b/src/lib/drizzle/schema.ts
@@ -7,10 +7,10 @@ const createdAt = timestamp("created_at", { withTimezone: true }).defaultNow()
const updatedAt = timestamp("updated_at", { withTimezone: true }).defaultNow().$onUpdate(() => new Date())
export const urls = pgTable("urls", {
- id: uuid("id").primaryKey().notNull(),
+ id: uuid("id").primaryKey().notNull().defaultRandom(),
url: varchar("url").notNull(),
slug: varchar("slug").unique().notNull(),
- title: varchar("title").notNull(),
+ title: varchar("title"),
maxVisits: integer("max_visits"),
expDate: timestamp("exp_date", { withTimezone: true }),
forwardQueryParams: boolean("forward_query_params").default(true),
diff --git a/src/lib/schema/url.ts b/src/lib/schema/url.ts
new file mode 100644
index 0000000..92e7436
--- /dev/null
+++ b/src/lib/schema/url.ts
@@ -0,0 +1,13 @@
+import { z } from "zod";
+
+export const urlFormSchema = z.object({
+ url: z.string().url("Please enter a valid URL"),
+ slug: z
+ .string()
+ .min(1, "Slug is required")
+ .max(50, "Slug must be 50 characters or less")
+ .regex(
+ /^[a-zA-Z0-9-_]+$/,
+ "Slug can only contain letters, numbers, hyphens, and underscores"
+ )
+})
\ No newline at end of file