├── .eslintrc.json
├── .gitignore
├── README.md
├── components.json
├── data
└── users.json
├── next.config.mjs
├── package-lock.json
├── package.json
├── postcss.config.mjs
├── public
├── next.svg
└── vercel.svg
├── src
├── app
│ ├── actions
│ │ └── actions.ts
│ ├── favicon.ico
│ ├── globals.css
│ ├── layout.tsx
│ ├── page.tsx
│ └── users
│ │ ├── @modal
│ │ ├── (.)edit
│ │ │ └── [id]
│ │ │ │ └── page.tsx
│ │ └── default.tsx
│ │ ├── UserRow.tsx
│ │ ├── edit
│ │ └── [id]
│ │ │ ├── UserForm.tsx
│ │ │ └── page.tsx
│ │ ├── layout.tsx
│ │ └── page.tsx
├── components
│ ├── AlertConfirmation.tsx
│ ├── DisplayServerActionResponse.tsx
│ ├── InputWithLabel.tsx
│ ├── Modal.tsx
│ └── ui
│ │ ├── alert-dialog.tsx
│ │ ├── button.tsx
│ │ ├── dialog.tsx
│ │ ├── form.tsx
│ │ ├── input.tsx
│ │ ├── label.tsx
│ │ └── table.tsx
├── lib
│ ├── getUser.ts
│ ├── getUsers.ts
│ ├── safe-action.ts
│ └── utils.ts
└── schemas
│ └── User.ts
├── tailwind.config.ts
└── tsconfig.json
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "next/core-web-vitals"
3 | }
4 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 | .yarn/install-state.gz
8 |
9 | # testing
10 | /coverage
11 |
12 | # next.js
13 | /.next/
14 | /out/
15 |
16 | # production
17 | /build
18 |
19 | # misc
20 | .DS_Store
21 | *.pem
22 |
23 | # debug
24 | npm-debug.log*
25 | yarn-debug.log*
26 | yarn-error.log*
27 |
28 | # local env files
29 | .env*.local
30 |
31 | # vercel
32 | .vercel
33 |
34 | # typescript
35 | *.tsbuildinfo
36 | next-env.d.ts
37 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # "next-safe-action Example Refactor
2 |
3 | _Add type safe and validated server actions to your Next.js App Router project with next-safe-action._
4 | ---
5 | ### Author Links
6 |
7 | 👋 Hello, I'm Dave Gray.
8 |
9 | 📚 [My Courses](https://courses.davegray.codes/)
10 |
11 | ✅ [Check out my YouTube Channel with hundreds of tutorials](https://www.youtube.com/DaveGrayTeachesCode).
12 |
13 | 🚩 [Subscribe to my channel](https://bit.ly/3nGHmNn)
14 |
15 | 💖 [Support My Content](https://patreon.com/davegray)
16 |
17 | 🚀 Follow Me:
18 |
19 | - [Twitter](https://twitter.com/yesdavidgray)
20 | - [LinkedIn](https://www.linkedin.com/in/davidagray/)
21 | - [Blog](https://davegray.codes)
22 |
23 | ---
24 |
25 | ### Description
26 |
27 | 📺 [YouTube Video](https://youtu.be/ahB3DgUMs1A) for this repository.
28 |
29 | ---
30 |
31 | ### ⚙ Usage
32 |
33 | - npx json-server -w data/users.json -p 3500
34 | - In a separate terminal window:
35 | - npm install
36 | - npm run dev
37 |
38 | ---
39 |
40 | ### 🎓 Academic Honesty
41 |
42 | **DO NOT COPY FOR AN ASSIGNMENT** - Avoid plagiarism and adhere to the spirit of this [Academic Honesty Policy](https://www.freecodecamp.org/news/academic-honesty-policy/).
43 |
44 | ---
45 |
46 | ### 📚 Tutorial References
47 |
48 | - 🔗 [next-safe-action](https://next-safe-action.dev/)
49 | - 🔗 [Next.js](https://nextjs.org/)
50 | - 🔗 [Next.js Server Actions](https://nextjs.org/docs/app/building-your-application/data-fetching/server-actions-and-mutations)
51 | - 🔗 [Zod](https://zod.dev/)
--------------------------------------------------------------------------------
/components.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://ui.shadcn.com/schema.json",
3 | "style": "default",
4 | "rsc": true,
5 | "tsx": true,
6 | "tailwind": {
7 | "config": "tailwind.config.ts",
8 | "css": "src/app/globals.css",
9 | "baseColor": "stone",
10 | "cssVariables": true,
11 | "prefix": ""
12 | },
13 | "aliases": {
14 | "components": "@/components",
15 | "utils": "@/lib/utils"
16 | }
17 | }
--------------------------------------------------------------------------------
/data/users.json:
--------------------------------------------------------------------------------
1 | {
2 | "users": [
3 | {
4 | "id": 1,
5 | "firstname": "Leanne",
6 | "lastname": "Graham",
7 | "email": "Sincere@april.biz"
8 | },
9 | {
10 | "id": 2,
11 | "firstname": "Ervin",
12 | "lastname": "Howell",
13 | "email": "Shanna@melissa.tv"
14 | },
15 | {
16 | "id": 3,
17 | "firstname": "Clementine",
18 | "lastname": "Bauch",
19 | "email": "Nathan@yesenia.net"
20 | },
21 | {
22 | "id": 4,
23 | "firstname": "Patricia",
24 | "lastname": "Lebsack",
25 | "email": "Julianne.OConner@kory.org"
26 | },
27 | {
28 | "id": 5,
29 | "firstname": "Chelsey",
30 | "lastname": "Dietrich",
31 | "email": "Lucio_Hettinger@annie.ca"
32 | },
33 | {
34 | "id": 6,
35 | "firstname": "Mrs. Dennis",
36 | "lastname": "Schulist",
37 | "email": "Karley_Dach@jasper.info"
38 | },
39 | {
40 | "id": 7,
41 | "firstname": "Kurtis",
42 | "lastname": "Weissnat",
43 | "email": "Telly.Hoeger@billy.biz"
44 | },
45 | {
46 | "id": 8,
47 | "firstname": "Nicholas",
48 | "lastname": "Runolfsdottir V",
49 | "email": "Sherwood@rosamond.me"
50 | },
51 | {
52 | "id": 9,
53 | "firstname": "Dave",
54 | "lastname": "Gray",
55 | "email": "dave@davegray.codes"
56 | },
57 | {
58 | "id": 10,
59 | "firstname": "Clementina",
60 | "lastname": "DuBuque",
61 | "email": "Rey.Padberg@karina.biz"
62 | }
63 | ]
64 | }
--------------------------------------------------------------------------------
/next.config.mjs:
--------------------------------------------------------------------------------
1 | /** @type {import('next').NextConfig} */
2 | const nextConfig = {};
3 |
4 | export default nextConfig;
5 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "next-form-modal",
3 | "version": "0.1.0",
4 | "private": false,
5 | "scripts": {
6 | "dev": "next dev",
7 | "build": "next build",
8 | "start": "next start",
9 | "lint": "next lint"
10 | },
11 | "dependencies": {
12 | "@hookform/resolvers": "^3.6.0",
13 | "@radix-ui/react-alert-dialog": "^1.0.5",
14 | "@radix-ui/react-dialog": "^1.0.5",
15 | "@radix-ui/react-label": "^2.0.2",
16 | "@radix-ui/react-slot": "^1.0.2",
17 | "class-variance-authority": "^0.7.0",
18 | "clsx": "^2.1.1",
19 | "lucide-react": "^0.394.0",
20 | "next": "14.2.3",
21 | "next-safe-action": "^7.0.2",
22 | "react": "^18",
23 | "react-dom": "^18",
24 | "react-hook-form": "^7.51.5",
25 | "tailwind-merge": "^2.3.0",
26 | "tailwindcss-animate": "^1.0.7",
27 | "zod": "^3.23.8",
28 | "zod-fetch": "^0.1.1"
29 | },
30 | "devDependencies": {
31 | "@types/node": "^20",
32 | "@types/react": "^18",
33 | "@types/react-dom": "^18",
34 | "eslint": "^8",
35 | "eslint-config-next": "14.2.3",
36 | "postcss": "^8",
37 | "tailwindcss": "^3.4.1",
38 | "typescript": "^5"
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/postcss.config.mjs:
--------------------------------------------------------------------------------
1 | /** @type {import('postcss-load-config').Config} */
2 | const config = {
3 | plugins: {
4 | tailwindcss: {},
5 | },
6 | };
7 |
8 | export default config;
9 |
--------------------------------------------------------------------------------
/public/next.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/vercel.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/app/actions/actions.ts:
--------------------------------------------------------------------------------
1 | "use server"
2 |
3 | import { UserSchema } from "@/schemas/User"
4 | import { actionClient } from "@/lib/safe-action"
5 | import { flattenValidationErrors } from "next-safe-action"
6 |
7 | export const saveUserAction = actionClient
8 | .schema(UserSchema, {
9 | handleValidationErrorsShape: (ve) => flattenValidationErrors(ve).fieldErrors,
10 | })
11 | .action(async ({ parsedInput: { id, firstname, lastname, email } }) => {
12 |
13 | await fetch(`http://localhost:3500/users/${id}`, {
14 | method: 'PATCH',
15 | headers: {
16 | "Content-Type": "application/json",
17 | },
18 | body: JSON.stringify({
19 | firstname: firstname,
20 | lastname: lastname,
21 | email: email,
22 | })
23 | })
24 |
25 | //throw new Error("Dave Error")
26 |
27 | return { message: "User Updated! 🎉" }
28 | })
--------------------------------------------------------------------------------
/src/app/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/gitdagray/next-safe-action-example/af8be3ca9f00a132bcf16e4df7a47550bf7de459/src/app/favicon.ico
--------------------------------------------------------------------------------
/src/app/globals.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | @layer base {
6 | :root {
7 | --background: 0 0% 100%;
8 | --foreground: 20 14.3% 4.1%;
9 |
10 | --card: 0 0% 100%;
11 | --card-foreground: 20 14.3% 4.1%;
12 |
13 | --popover: 0 0% 100%;
14 | --popover-foreground: 20 14.3% 4.1%;
15 |
16 | --primary: 24 9.8% 10%;
17 | --primary-foreground: 60 9.1% 97.8%;
18 |
19 | --secondary: 60 4.8% 95.9%;
20 | --secondary-foreground: 24 9.8% 10%;
21 |
22 | --muted: 60 4.8% 95.9%;
23 | --muted-foreground: 25 5.3% 44.7%;
24 |
25 | --accent: 60 4.8% 95.9%;
26 | --accent-foreground: 24 9.8% 10%;
27 |
28 | --destructive: 0 84.2% 60.2%;
29 | --destructive-foreground: 60 9.1% 97.8%;
30 |
31 | --border: 20 5.9% 90%;
32 | --input: 20 5.9% 90%;
33 | --ring: 20 14.3% 4.1%;
34 |
35 | --radius: 0.5rem;
36 | }
37 |
38 | :root {
39 | --background: 20 14.3% 4.1%;
40 | --foreground: 60 9.1% 97.8%;
41 |
42 | --card: 20 14.3% 4.1%;
43 | --card-foreground: 60 9.1% 97.8%;
44 |
45 | --popover: 20 14.3% 4.1%;
46 | --popover-foreground: 60 9.1% 97.8%;
47 |
48 | --primary: 60 9.1% 97.8%;
49 | --primary-foreground: 24 9.8% 10%;
50 |
51 | --secondary: 12 6.5% 15.1%;
52 | --secondary-foreground: 60 9.1% 97.8%;
53 |
54 | --muted: 12 6.5% 15.1%;
55 | --muted-foreground: 24 5.4% 63.9%;
56 |
57 | --accent: 12 6.5% 15.1%;
58 | --accent-foreground: 60 9.1% 97.8%;
59 |
60 | --destructive: 0 62.8% 30.6%;
61 | --destructive-foreground: 60 9.1% 97.8%;
62 |
63 | --border: 12 6.5% 15.1%;
64 | --input: 12 6.5% 15.1%;
65 | --ring: 24 5.7% 82.9%;
66 | }
67 | }
68 |
69 | @layer base {
70 | * {
71 | @apply border-border;
72 | }
73 |
74 | body {
75 | @apply bg-background text-foreground;
76 | }
77 | }
--------------------------------------------------------------------------------
/src/app/layout.tsx:
--------------------------------------------------------------------------------
1 | import type { Metadata } from "next";
2 | import { Inter } from "next/font/google";
3 | import "./globals.css";
4 |
5 | const inter = Inter({ subsets: ["latin"] });
6 |
7 | export const metadata: Metadata = {
8 | title: "Create Next App",
9 | description: "Generated by create next app",
10 | };
11 |
12 | export default function RootLayout({
13 | children,
14 | }: Readonly<{
15 | children: React.ReactNode;
16 | }>) {
17 | return (
18 |
19 |
{children}
20 |
21 | );
22 | }
23 |
--------------------------------------------------------------------------------
/src/app/page.tsx:
--------------------------------------------------------------------------------
1 | import Link from "next/link"
2 |
3 | export default function Home() {
4 | return (
5 |
6 | View Users
10 |
11 | )
12 | }
13 |
--------------------------------------------------------------------------------
/src/app/users/@modal/(.)edit/[id]/page.tsx:
--------------------------------------------------------------------------------
1 | import UserForm from "@/app/users/edit/[id]/UserForm"
2 | import { getUser } from "@/lib/getUser"
3 | import { Modal } from "@/components/Modal"
4 |
5 | type Props = {
6 | params: {
7 | id: number,
8 | }
9 | }
10 |
11 | export default async function EditUser({ params }: Props) {
12 | const { id } = params
13 |
14 | const user = await getUser(id)
15 |
16 | if (!user?.id) {
17 | return (
18 |
19 |
20 |
21 | No User Found for that ID.
22 |
23 |
24 |
25 | )
26 | }
27 |
28 | return (
29 |
30 |
31 |
Edit User {id}
32 |
33 |
34 |
35 | )
36 | }
--------------------------------------------------------------------------------
/src/app/users/@modal/default.tsx:
--------------------------------------------------------------------------------
1 | export default function Default() {
2 | return null
3 | }
--------------------------------------------------------------------------------
/src/app/users/UserRow.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 | import {
3 | TableCell,
4 | TableRow,
5 | } from "@/components/ui/table"
6 | import { useRouter } from "next/navigation"
7 | import type { User } from "@/schemas/User"
8 |
9 | type Props = {
10 | user: User
11 | }
12 |
13 | export default function UserRow({ user }: Props) {
14 | const router = useRouter()
15 |
16 | const handleClick = () => {
17 | router.push(`/users/edit/${user.id}`)
18 | }
19 |
20 | return (
21 |
22 |
23 | {user.email}
24 |
25 |
26 | {user.firstname}
27 |
28 |
29 | {user.lastname}
30 |
31 |
32 | )
33 | }
--------------------------------------------------------------------------------
/src/app/users/edit/[id]/UserForm.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import { useForm } from "react-hook-form"
4 | import { Form } from "@/components/ui/form"
5 | import { Button } from "@/components/ui/button"
6 | import { InputWithLabel } from "@/components/InputWithLabel"
7 | import { zodResolver } from "@hookform/resolvers/zod"
8 | import { UserSchema } from "@/schemas/User"
9 | import type { User } from "@/schemas/User"
10 | import { saveUserAction } from "@/app/actions/actions"
11 | import { useEffect } from "react"
12 | import { useRouter } from "next/navigation"
13 |
14 | import { useAction } from "next-safe-action/hooks"
15 | import { DisplayServerActionResponse } from "@/components/DisplayServerActionResponse"
16 |
17 | type Props = {
18 | user: User
19 | }
20 |
21 | export default function UserForm({ user }: Props) {
22 | const router = useRouter()
23 | const { execute, result, isExecuting } = useAction(saveUserAction)
24 |
25 | const form = useForm({
26 | resolver: zodResolver(UserSchema),
27 | defaultValues: { ...user },
28 | })
29 |
30 | useEffect(() => {
31 | // boolean value to indicate form has not been saved
32 | localStorage.setItem("userFormModified", form.formState.isDirty.toString())
33 | }, [form.formState.isDirty])
34 |
35 | async function onSubmit() {
36 | /* No need to validate here because
37 | react-hook-form already validates with
38 | our Zod schema */
39 |
40 | // Test validation errors:
41 | // execute({
42 | // id: 9,
43 | // firstname: "D",
44 | // lastname: "G",
45 | // email: "davegray.codes",
46 | // })
47 | execute(form.getValues())
48 | router.refresh() // could grab a new timestamp from db
49 | // reset dirty fields
50 | form.reset(form.getValues())
51 | //}
52 | }
53 |
54 | return (
55 |
56 |
57 |
58 |
59 |
86 |
87 |
88 | )
89 | }
--------------------------------------------------------------------------------
/src/app/users/edit/[id]/page.tsx:
--------------------------------------------------------------------------------
1 | import UserForm from "./UserForm"
2 | import { getUser } from "@/lib/getUser"
3 |
4 | type Props = {
5 | params: {
6 | id: number,
7 | }
8 | }
9 |
10 | export default async function EditUser({ params }: Props) {
11 | const { id } = params
12 |
13 | const user = await getUser(id)
14 |
15 | if (!user?.id) {
16 | return (
17 |
18 |
19 | No User Found for that ID.
20 |
21 |
22 | )
23 | }
24 |
25 | return (
26 |
27 |
Edit User {id}
28 |
29 |
30 | )
31 | }
--------------------------------------------------------------------------------
/src/app/users/layout.tsx:
--------------------------------------------------------------------------------
1 |
2 | export default function UsersLayout({
3 | children,
4 | modal,
5 | }: Readonly<{
6 | children: React.ReactNode;
7 | modal: React.ReactNode;
8 | }>) {
9 | return (
10 | <>
11 | {modal}
12 | {children}
13 | >
14 | );
15 | }
16 |
--------------------------------------------------------------------------------
/src/app/users/page.tsx:
--------------------------------------------------------------------------------
1 | import { getUsers } from "@/lib/getUsers"
2 | import {
3 | Table,
4 | TableBody,
5 | TableHead,
6 | TableHeader,
7 | TableRow,
8 | } from "@/components/ui/table"
9 | import UserRow from "./UserRow"
10 |
11 | export default async function Users() {
12 | const usersData = await getUsers()
13 |
14 | return (
15 |
16 |
Users List
17 |
18 |
19 |
20 | Email
21 | First
22 | Last
23 |
24 |
25 |
26 | {usersData.map(user => (
27 |
28 | ))}
29 |
30 |
31 |
32 | )
33 | }
--------------------------------------------------------------------------------
/src/components/AlertConfirmation.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | AlertDialog,
3 | AlertDialogAction,
4 | AlertDialogCancel,
5 | AlertDialogContent,
6 | AlertDialogDescription,
7 | AlertDialogFooter,
8 | AlertDialogHeader,
9 | AlertDialogTitle,
10 | } from "@/components/ui/alert-dialog"
11 |
12 | type Props = {
13 | open: boolean,
14 | setOpen: React.Dispatch>,
15 | confirmationAction: () => void,
16 | message: string,
17 | }
18 |
19 | export function AlertConfirmation({
20 | open, setOpen, confirmationAction, message,
21 | }: Props) {
22 | return (
23 |
24 |
25 |
26 | Are you sure?
27 |
28 | {message}
29 |
30 |
31 |
32 | Cancel
33 | Continue
36 |
37 |
38 |
39 | )
40 | }
--------------------------------------------------------------------------------
/src/components/DisplayServerActionResponse.tsx:
--------------------------------------------------------------------------------
1 | type Props = {
2 | result: {
3 | data?: {
4 | message?: string,
5 | },
6 | serverError?: string,
7 | fetchError?: string,
8 | validationErrors?: Record | undefined,
9 |
10 | }
11 | }
12 |
13 | export function DisplayServerActionResponse({ result }: Props) {
14 |
15 | const { data, serverError, fetchError, validationErrors } = result
16 |
17 | return (
18 | <>
19 | {/* Success Message */}
20 | {data?.message ? (
21 | {data.message}
22 | ) : null}
23 |
24 | {serverError ? (
25 |
28 | ) : null}
29 |
30 | {fetchError ? (
31 |
34 | ) : null}
35 |
36 | {validationErrors ? (
37 |
38 | {Object.keys(validationErrors).map(key => (
39 |
{`${key}: ${validationErrors && validationErrors[key as keyof typeof validationErrors]}`}
40 | ))}
41 |
42 | ) : null}
43 | >
44 | )
45 | }
--------------------------------------------------------------------------------
/src/components/InputWithLabel.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import { useFormContext } from "react-hook-form"
4 | import { Input } from "@/components/ui/input"
5 | import { Button } from "@/components/ui/button"
6 | import { XIcon } from "lucide-react"
7 | import {
8 | FormField,
9 | FormControl,
10 | FormItem,
11 | FormLabel,
12 | FormMessage,
13 | } from "@/components/ui/form"
14 |
15 | type Props = {
16 | fieldTitle: string,
17 | nameInSchema: string,
18 | placeholder?: string,
19 | labelLeft?: boolean,
20 | readOnly?: boolean,
21 | }
22 |
23 | export function InputWithLabel({ fieldTitle, nameInSchema, placeholder, labelLeft, readOnly }: Props) {
24 | const form = useFormContext()
25 |
26 | const fieldTitleNoSpaces = fieldTitle.replaceAll(' ', '-')
27 |
28 | return (
29 | (
33 |
34 |
35 | {fieldTitle}
36 |
37 |
38 |
39 |
40 |
41 | field.onChange(e.target.value)}
50 | />
51 |
52 |
53 | {!readOnly ? (
54 |
67 | ) : null}
68 |
69 |
70 |
71 |
72 | )}
73 | />
74 | )
75 | }
76 |
--------------------------------------------------------------------------------
/src/components/Modal.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import {
4 | Dialog,
5 | DialogOverlay,
6 | DialogContent,
7 | } from "@/components/ui/dialog"
8 | import { AlertConfirmation } from "./AlertConfirmation"
9 | import { useRouter } from "next/navigation"
10 | import { useState } from 'react'
11 |
12 | export function Modal({
13 | children,
14 | }: {
15 | children: React.ReactNode
16 | }) {
17 | const [showExitConfirmation, setShowExitConfirmation] = useState(false)
18 | const router = useRouter()
19 |
20 | const closeModal = () => {
21 | router.back()
22 | }
23 |
24 | const handleOpenChange = () => {
25 | const isUserFormModified = localStorage.getItem("userFormModified")
26 | if (isUserFormModified && JSON.parse(isUserFormModified)) {
27 | setShowExitConfirmation(true)
28 | } else {
29 | router.back()
30 | }
31 | }
32 |
33 | return (
34 |
47 | )
48 | }
--------------------------------------------------------------------------------
/src/components/ui/alert-dialog.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
5 |
6 | import { cn } from "@/lib/utils"
7 | import { buttonVariants } from "@/components/ui/button"
8 |
9 | const AlertDialog = AlertDialogPrimitive.Root
10 |
11 | const AlertDialogTrigger = AlertDialogPrimitive.Trigger
12 |
13 | const AlertDialogPortal = AlertDialogPrimitive.Portal
14 |
15 | const AlertDialogOverlay = React.forwardRef<
16 | React.ElementRef,
17 | React.ComponentPropsWithoutRef
18 | >(({ className, ...props }, ref) => (
19 |
27 | ))
28 | AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
29 |
30 | const AlertDialogContent = React.forwardRef<
31 | React.ElementRef,
32 | React.ComponentPropsWithoutRef
33 | >(({ className, ...props }, ref) => (
34 |
35 |
36 |
44 |
45 | ))
46 | AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
47 |
48 | const AlertDialogHeader = ({
49 | className,
50 | ...props
51 | }: React.HTMLAttributes) => (
52 |
59 | )
60 | AlertDialogHeader.displayName = "AlertDialogHeader"
61 |
62 | const AlertDialogFooter = ({
63 | className,
64 | ...props
65 | }: React.HTMLAttributes) => (
66 |
73 | )
74 | AlertDialogFooter.displayName = "AlertDialogFooter"
75 |
76 | const AlertDialogTitle = React.forwardRef<
77 | React.ElementRef,
78 | React.ComponentPropsWithoutRef
79 | >(({ className, ...props }, ref) => (
80 |
85 | ))
86 | AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
87 |
88 | const AlertDialogDescription = React.forwardRef<
89 | React.ElementRef,
90 | React.ComponentPropsWithoutRef
91 | >(({ className, ...props }, ref) => (
92 |
97 | ))
98 | AlertDialogDescription.displayName =
99 | AlertDialogPrimitive.Description.displayName
100 |
101 | const AlertDialogAction = React.forwardRef<
102 | React.ElementRef,
103 | React.ComponentPropsWithoutRef
104 | >(({ className, ...props }, ref) => (
105 |
110 | ))
111 | AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
112 |
113 | const AlertDialogCancel = React.forwardRef<
114 | React.ElementRef,
115 | React.ComponentPropsWithoutRef
116 | >(({ className, ...props }, ref) => (
117 |
126 | ))
127 | AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
128 |
129 | export {
130 | AlertDialog,
131 | AlertDialogPortal,
132 | AlertDialogOverlay,
133 | AlertDialogTrigger,
134 | AlertDialogContent,
135 | AlertDialogHeader,
136 | AlertDialogFooter,
137 | AlertDialogTitle,
138 | AlertDialogDescription,
139 | AlertDialogAction,
140 | AlertDialogCancel,
141 | }
142 |
--------------------------------------------------------------------------------
/src/components/ui/button.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import { Slot } from "@radix-ui/react-slot"
3 | import { cva, type VariantProps } from "class-variance-authority"
4 |
5 | import { cn } from "@/lib/utils"
6 |
7 | const buttonVariants = cva(
8 | "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
9 | {
10 | variants: {
11 | variant: {
12 | default: "bg-primary text-primary-foreground hover:bg-primary/90",
13 | destructive:
14 | "bg-destructive text-destructive-foreground hover:bg-destructive/90",
15 | outline:
16 | "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
17 | secondary:
18 | "bg-secondary text-secondary-foreground hover:bg-secondary/80",
19 | ghost: "hover:bg-accent hover:text-accent-foreground",
20 | link: "text-primary underline-offset-4 hover:underline",
21 | },
22 | size: {
23 | default: "h-10 px-4 py-2",
24 | sm: "h-9 rounded-md px-3",
25 | lg: "h-11 rounded-md px-8",
26 | icon: "h-10 w-10",
27 | },
28 | },
29 | defaultVariants: {
30 | variant: "default",
31 | size: "default",
32 | },
33 | }
34 | )
35 |
36 | export interface ButtonProps
37 | extends React.ButtonHTMLAttributes,
38 | VariantProps {
39 | asChild?: boolean
40 | }
41 |
42 | const Button = React.forwardRef(
43 | ({ className, variant, size, asChild = false, ...props }, ref) => {
44 | const Comp = asChild ? Slot : "button"
45 | return (
46 |
51 | )
52 | }
53 | )
54 | Button.displayName = "Button"
55 |
56 | export { Button, buttonVariants }
57 |
--------------------------------------------------------------------------------
/src/components/ui/dialog.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as DialogPrimitive from "@radix-ui/react-dialog"
5 | import { X } from "lucide-react"
6 |
7 | import { cn } from "@/lib/utils"
8 |
9 | const Dialog = DialogPrimitive.Root
10 |
11 | const DialogTrigger = DialogPrimitive.Trigger
12 |
13 | const DialogPortal = DialogPrimitive.Portal
14 |
15 | const DialogClose = DialogPrimitive.Close
16 |
17 | const DialogOverlay = React.forwardRef<
18 | React.ElementRef,
19 | React.ComponentPropsWithoutRef
20 | >(({ className, ...props }, ref) => (
21 |
29 | ))
30 | DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
31 |
32 | const DialogContent = React.forwardRef<
33 | React.ElementRef,
34 | React.ComponentPropsWithoutRef
35 | >(({ className, children, ...props }, ref) => (
36 |
37 |
38 |
46 | {children}
47 |
48 |
49 | Close
50 |
51 |
52 |
53 | ))
54 | DialogContent.displayName = DialogPrimitive.Content.displayName
55 |
56 | const DialogHeader = ({
57 | className,
58 | ...props
59 | }: React.HTMLAttributes) => (
60 |
67 | )
68 | DialogHeader.displayName = "DialogHeader"
69 |
70 | const DialogFooter = ({
71 | className,
72 | ...props
73 | }: React.HTMLAttributes) => (
74 |
81 | )
82 | DialogFooter.displayName = "DialogFooter"
83 |
84 | const DialogTitle = React.forwardRef<
85 | React.ElementRef,
86 | React.ComponentPropsWithoutRef
87 | >(({ className, ...props }, ref) => (
88 |
96 | ))
97 | DialogTitle.displayName = DialogPrimitive.Title.displayName
98 |
99 | const DialogDescription = React.forwardRef<
100 | React.ElementRef,
101 | React.ComponentPropsWithoutRef
102 | >(({ className, ...props }, ref) => (
103 |
108 | ))
109 | DialogDescription.displayName = DialogPrimitive.Description.displayName
110 |
111 | export {
112 | Dialog,
113 | DialogPortal,
114 | DialogOverlay,
115 | DialogClose,
116 | DialogTrigger,
117 | DialogContent,
118 | DialogHeader,
119 | DialogFooter,
120 | DialogTitle,
121 | DialogDescription,
122 | }
123 |
--------------------------------------------------------------------------------
/src/components/ui/form.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import * as LabelPrimitive from "@radix-ui/react-label"
3 | import { Slot } from "@radix-ui/react-slot"
4 | import {
5 | Controller,
6 | ControllerProps,
7 | FieldPath,
8 | FieldValues,
9 | FormProvider,
10 | useFormContext,
11 | } from "react-hook-form"
12 |
13 | import { cn } from "@/lib/utils"
14 | import { Label } from "@/components/ui/label"
15 |
16 | const Form = FormProvider
17 |
18 | type FormFieldContextValue<
19 | TFieldValues extends FieldValues = FieldValues,
20 | TName extends FieldPath = FieldPath
21 | > = {
22 | name: TName
23 | }
24 |
25 | const FormFieldContext = React.createContext(
26 | {} as FormFieldContextValue
27 | )
28 |
29 | const FormField = <
30 | TFieldValues extends FieldValues = FieldValues,
31 | TName extends FieldPath = FieldPath
32 | >({
33 | ...props
34 | }: ControllerProps) => {
35 | return (
36 |
37 |
38 |
39 | )
40 | }
41 |
42 | const useFormField = () => {
43 | const fieldContext = React.useContext(FormFieldContext)
44 | const itemContext = React.useContext(FormItemContext)
45 | const { getFieldState, formState } = useFormContext()
46 |
47 | const fieldState = getFieldState(fieldContext.name, formState)
48 |
49 | if (!fieldContext) {
50 | throw new Error("useFormField should be used within ")
51 | }
52 |
53 | const { id } = itemContext
54 |
55 | return {
56 | id,
57 | name: fieldContext.name,
58 | formItemId: `${id}-form-item`,
59 | formDescriptionId: `${id}-form-item-description`,
60 | formMessageId: `${id}-form-item-message`,
61 | ...fieldState,
62 | }
63 | }
64 |
65 | type FormItemContextValue = {
66 | id: string
67 | }
68 |
69 | const FormItemContext = React.createContext(
70 | {} as FormItemContextValue
71 | )
72 |
73 | const FormItem = React.forwardRef<
74 | HTMLDivElement,
75 | React.HTMLAttributes
76 | >(({ className, ...props }, ref) => {
77 | const id = React.useId()
78 |
79 | return (
80 |
81 |
82 |
83 | )
84 | })
85 | FormItem.displayName = "FormItem"
86 |
87 | const FormLabel = React.forwardRef<
88 | React.ElementRef,
89 | React.ComponentPropsWithoutRef
90 | >(({ className, ...props }, ref) => {
91 | const { error, formItemId } = useFormField()
92 |
93 | return (
94 |
100 | )
101 | })
102 | FormLabel.displayName = "FormLabel"
103 |
104 | const FormControl = React.forwardRef<
105 | React.ElementRef,
106 | React.ComponentPropsWithoutRef
107 | >(({ ...props }, ref) => {
108 | const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
109 |
110 | return (
111 |
122 | )
123 | })
124 | FormControl.displayName = "FormControl"
125 |
126 | const FormDescription = React.forwardRef<
127 | HTMLParagraphElement,
128 | React.HTMLAttributes
129 | >(({ className, ...props }, ref) => {
130 | const { formDescriptionId } = useFormField()
131 |
132 | return (
133 |
139 | )
140 | })
141 | FormDescription.displayName = "FormDescription"
142 |
143 | const FormMessage = React.forwardRef<
144 | HTMLParagraphElement,
145 | React.HTMLAttributes
146 | >(({ className, children, ...props }, ref) => {
147 | const { error, formMessageId } = useFormField()
148 | const body = error ? String(error?.message) : children
149 |
150 | if (!body) {
151 | return null
152 | }
153 |
154 | return (
155 |
161 | {body}
162 |
163 | )
164 | })
165 | FormMessage.displayName = "FormMessage"
166 |
167 | export {
168 | useFormField,
169 | Form,
170 | FormItem,
171 | FormLabel,
172 | FormControl,
173 | FormDescription,
174 | FormMessage,
175 | FormField,
176 | }
177 |
--------------------------------------------------------------------------------
/src/components/ui/input.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 |
3 | import { cn } from "@/lib/utils"
4 |
5 | export interface InputProps
6 | extends React.InputHTMLAttributes {}
7 |
8 | const Input = React.forwardRef(
9 | ({ className, type, ...props }, ref) => {
10 | return (
11 |
20 | )
21 | }
22 | )
23 | Input.displayName = "Input"
24 |
25 | export { Input }
26 |
--------------------------------------------------------------------------------
/src/components/ui/label.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as LabelPrimitive from "@radix-ui/react-label"
5 | import { cva, type VariantProps } from "class-variance-authority"
6 |
7 | import { cn } from "@/lib/utils"
8 |
9 | const labelVariants = cva(
10 | "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
11 | )
12 |
13 | const Label = React.forwardRef<
14 | React.ElementRef,
15 | React.ComponentPropsWithoutRef &
16 | VariantProps
17 | >(({ className, ...props }, ref) => (
18 |
23 | ))
24 | Label.displayName = LabelPrimitive.Root.displayName
25 |
26 | export { Label }
27 |
--------------------------------------------------------------------------------
/src/components/ui/table.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 |
3 | import { cn } from "@/lib/utils"
4 |
5 | const Table = React.forwardRef<
6 | HTMLTableElement,
7 | React.HTMLAttributes
8 | >(({ className, ...props }, ref) => (
9 |
16 | ))
17 | Table.displayName = "Table"
18 |
19 | const TableHeader = React.forwardRef<
20 | HTMLTableSectionElement,
21 | React.HTMLAttributes
22 | >(({ className, ...props }, ref) => (
23 |
24 | ))
25 | TableHeader.displayName = "TableHeader"
26 |
27 | const TableBody = React.forwardRef<
28 | HTMLTableSectionElement,
29 | React.HTMLAttributes
30 | >(({ className, ...props }, ref) => (
31 |
36 | ))
37 | TableBody.displayName = "TableBody"
38 |
39 | const TableFooter = React.forwardRef<
40 | HTMLTableSectionElement,
41 | React.HTMLAttributes
42 | >(({ className, ...props }, ref) => (
43 | tr]:last:border-b-0",
47 | className
48 | )}
49 | {...props}
50 | />
51 | ))
52 | TableFooter.displayName = "TableFooter"
53 |
54 | const TableRow = React.forwardRef<
55 | HTMLTableRowElement,
56 | React.HTMLAttributes
57 | >(({ className, ...props }, ref) => (
58 |
66 | ))
67 | TableRow.displayName = "TableRow"
68 |
69 | const TableHead = React.forwardRef<
70 | HTMLTableCellElement,
71 | React.ThHTMLAttributes
72 | >(({ className, ...props }, ref) => (
73 | |
81 | ))
82 | TableHead.displayName = "TableHead"
83 |
84 | const TableCell = React.forwardRef<
85 | HTMLTableCellElement,
86 | React.TdHTMLAttributes
87 | >(({ className, ...props }, ref) => (
88 | |
93 | ))
94 | TableCell.displayName = "TableCell"
95 |
96 | const TableCaption = React.forwardRef<
97 | HTMLTableCaptionElement,
98 | React.HTMLAttributes
99 | >(({ className, ...props }, ref) => (
100 |
105 | ))
106 | TableCaption.displayName = "TableCaption"
107 |
108 | export {
109 | Table,
110 | TableHeader,
111 | TableBody,
112 | TableFooter,
113 | TableHead,
114 | TableRow,
115 | TableCell,
116 | TableCaption,
117 | }
118 |
--------------------------------------------------------------------------------
/src/lib/getUser.ts:
--------------------------------------------------------------------------------
1 | import { createZodFetcher } from "zod-fetch"
2 | import { UserSchema } from "@/schemas/User"
3 |
4 | const fetchUser = createZodFetcher()
5 |
6 | export async function getUser(id: number) {
7 | const user = await fetchUser(
8 | UserSchema,
9 | `http://localhost:3500/users/${id}`,
10 | { cache: 'no-store' },
11 | )
12 |
13 | return user
14 | }
--------------------------------------------------------------------------------
/src/lib/getUsers.ts:
--------------------------------------------------------------------------------
1 | import { z } from "zod"
2 | import { createZodFetcher } from "zod-fetch"
3 | import { UserSchema } from "@/schemas/User"
4 |
5 | const fetchUsers = createZodFetcher()
6 |
7 | export async function getUsers() {
8 | const users = await fetchUsers(
9 | z.array(UserSchema),
10 | 'http://localhost:3500/users',
11 | { cache: 'no-store' },
12 | )
13 |
14 | return users
15 | }
--------------------------------------------------------------------------------
/src/lib/safe-action.ts:
--------------------------------------------------------------------------------
1 | import { createSafeActionClient } from "next-safe-action"
2 |
3 | export const actionClient = createSafeActionClient({
4 | handleReturnedServerError(e) {
5 | return "Oh no, something went wrong!";
6 | },
7 | })
--------------------------------------------------------------------------------
/src/lib/utils.ts:
--------------------------------------------------------------------------------
1 | import { type ClassValue, clsx } from "clsx"
2 | import { twMerge } from "tailwind-merge"
3 |
4 | export function cn(...inputs: ClassValue[]) {
5 | return twMerge(clsx(inputs))
6 | }
7 |
--------------------------------------------------------------------------------
/src/schemas/User.ts:
--------------------------------------------------------------------------------
1 | import { z } from "zod"
2 |
3 | export const UserSchema = z.object({
4 | id: z.number().int().positive(),
5 | firstname: z.string().min(2, { message: "Must be 2 or more characters long" }),
6 | lastname: z.string().min(2, { message: "Must be 2 or more characters long" }),
7 | email: z.string().email({ message: "Invalid email address" }),
8 | })
9 |
10 | export type User = z.infer
--------------------------------------------------------------------------------
/tailwind.config.ts:
--------------------------------------------------------------------------------
1 | import type { Config } from "tailwindcss"
2 |
3 | const config = {
4 | darkMode: ["class"],
5 | content: [
6 | './pages/**/*.{ts,tsx}',
7 | './components/**/*.{ts,tsx}',
8 | './app/**/*.{ts,tsx}',
9 | './src/**/*.{ts,tsx}',
10 | ],
11 | prefix: "",
12 | theme: {
13 | container: {
14 | center: true,
15 | padding: "2rem",
16 | screens: {
17 | "2xl": "1400px",
18 | },
19 | },
20 | extend: {
21 | colors: {
22 | border: "hsl(var(--border))",
23 | input: "hsl(var(--input))",
24 | ring: "hsl(var(--ring))",
25 | background: "hsl(var(--background))",
26 | foreground: "hsl(var(--foreground))",
27 | primary: {
28 | DEFAULT: "hsl(var(--primary))",
29 | foreground: "hsl(var(--primary-foreground))",
30 | },
31 | secondary: {
32 | DEFAULT: "hsl(var(--secondary))",
33 | foreground: "hsl(var(--secondary-foreground))",
34 | },
35 | destructive: {
36 | DEFAULT: "hsl(var(--destructive))",
37 | foreground: "hsl(var(--destructive-foreground))",
38 | },
39 | muted: {
40 | DEFAULT: "hsl(var(--muted))",
41 | foreground: "hsl(var(--muted-foreground))",
42 | },
43 | accent: {
44 | DEFAULT: "hsl(var(--accent))",
45 | foreground: "hsl(var(--accent-foreground))",
46 | },
47 | popover: {
48 | DEFAULT: "hsl(var(--popover))",
49 | foreground: "hsl(var(--popover-foreground))",
50 | },
51 | card: {
52 | DEFAULT: "hsl(var(--card))",
53 | foreground: "hsl(var(--card-foreground))",
54 | },
55 | },
56 | borderRadius: {
57 | lg: "var(--radius)",
58 | md: "calc(var(--radius) - 2px)",
59 | sm: "calc(var(--radius) - 4px)",
60 | },
61 | keyframes: {
62 | "accordion-down": {
63 | from: { height: "0" },
64 | to: { height: "var(--radix-accordion-content-height)" },
65 | },
66 | "accordion-up": {
67 | from: { height: "var(--radix-accordion-content-height)" },
68 | to: { height: "0" },
69 | },
70 | },
71 | animation: {
72 | "accordion-down": "accordion-down 0.2s ease-out",
73 | "accordion-up": "accordion-up 0.2s ease-out",
74 | },
75 | },
76 | },
77 | plugins: [require("tailwindcss-animate")],
78 | } satisfies Config
79 |
80 | export default config
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "lib": ["dom", "dom.iterable", "esnext"],
4 | "allowJs": true,
5 | "skipLibCheck": true,
6 | "strict": true,
7 | "noEmit": true,
8 | "esModuleInterop": true,
9 | "module": "esnext",
10 | "moduleResolution": "bundler",
11 | "resolveJsonModule": true,
12 | "isolatedModules": true,
13 | "jsx": "preserve",
14 | "incremental": true,
15 | "plugins": [
16 | {
17 | "name": "next"
18 | }
19 | ],
20 | "paths": {
21 | "@/*": ["./src/*"]
22 | }
23 | },
24 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
25 | "exclude": ["node_modules"]
26 | }
27 |
--------------------------------------------------------------------------------