diff --git a/.gitignore b/.gitignore index fd3dbb5..b9f8d23 100644 --- a/.gitignore +++ b/.gitignore @@ -34,3 +34,5 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts + +.env \ No newline at end of file diff --git a/README.md b/README.md index c403366..a885fe5 100644 --- a/README.md +++ b/README.md @@ -1,36 +1,9 @@ -This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). +# Next.js 14 Job Board -## Getting Started +This tutorial project focuses heavily on **Next.js server actions**. -First, run the development server: +It shows how to use server actions from server components, from client components, with and without React Hook Form, and how to achieve **progressive enhancement** with the `useFormStatus` & `useFormState` hooks. -```bash -npm run dev -# or -yarn dev -# or -pnpm dev -# or -bun dev -``` +Watch the tutorial for this project on YouTube: https://www.youtube.com/watch?v=XD5FpbVpWzk -Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. - -You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. - -This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font. - -## Learn More - -To learn more about Next.js, take a look at the following resources: - -- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. -- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. - -You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome! - -## Deploy on Vercel - -The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. - -Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. +![thumbnail 2](https://github.com/codinginflow/nextjs-job-board/assets/52977034/7014ed7a-696d-4c35-a6de-16e28f7460d3) diff --git a/next.config.js b/next.config.js index 767719f..0c89f98 100644 --- a/next.config.js +++ b/next.config.js @@ -1,4 +1,12 @@ /** @type {import('next').NextConfig} */ -const nextConfig = {} +const nextConfig = { + images: { + remotePatterns: [ + { + hostname: "rqcoa3ubmzn9qpsj.public.blob.vercel-storage.com", + }, + ], + }, +}; -module.exports = nextConfig +module.exports = nextConfig; diff --git a/package.json b/package.json index 97f700d..5570ce9 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,8 @@ "build": "next build", "start": "next start", "lint": "next lint", - "seed": "node scripts/seed.js" + "seed": "node scripts/seed.js", + "postinstall": "prisma generate" }, "dependencies": { "@clerk/nextjs": "^4.29.1", diff --git a/prisma/schema.prisma b/prisma/schema.prisma new file mode 100644 index 0000000..24bf142 --- /dev/null +++ b/prisma/schema.prisma @@ -0,0 +1,33 @@ +// This is your Prisma schema file, +// learn more about it in the docs: https://pris.ly/d/prisma-schema + +generator client { + provider = "prisma-client-js" + previewFeatures = ["fullTextSearch"] +} + +datasource db { + provider = "postgresql" + url = env("POSTGRES_PRISMA_URL") + directUrl = env("POSTGRES_URL_NON_POOLING") +} + +model Job { + id Int @id @default(autoincrement()) + slug String @unique + title String + type String + locationType String + location String? + description String? + salary Int + companyName String + applicationEmail String? + applicationUrl String? + companyLogoUrl String? + approved Boolean @default(false) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@map("jobs") +} diff --git a/src/app/admin/AdminNavbar.tsx b/src/app/admin/AdminNavbar.tsx new file mode 100644 index 0000000..b80fc5e --- /dev/null +++ b/src/app/admin/AdminNavbar.tsx @@ -0,0 +1,34 @@ +"use client"; + +import { useClerk } from "@clerk/nextjs"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; + +export default function AdminNavbar() { + const { user, signOut } = useClerk(); + const router = useRouter(); + + return ( +
+
+ + Admin Dashboard + +
+ + {user?.primaryEmailAddress?.emailAddress} + + +
+
+
+ ); +} diff --git a/src/app/admin/jobs/[slug]/AdminSidebar.tsx b/src/app/admin/jobs/[slug]/AdminSidebar.tsx new file mode 100644 index 0000000..fdf79e3 --- /dev/null +++ b/src/app/admin/jobs/[slug]/AdminSidebar.tsx @@ -0,0 +1,61 @@ +"use client"; + +import FormSubmitButton from "@/components/FormSubmitButton"; +import { Job } from "@prisma/client"; +import { useFormState } from "react-dom"; +import { approveSubmission, deleteJob } from "./actions"; + +interface AdminSidebarProps { + job: Job; +} + +export default function AdminSidebar({ job }: AdminSidebarProps) { + return ( + + ); +} + +interface AdminButtonProps { + jobId: number; +} + +function ApproveSubmissionButton({ jobId }: AdminButtonProps) { + const [formState, formAction] = useFormState(approveSubmission, undefined); + + return ( +
+ + + Approve + + {formState?.error && ( +

{formState.error}

+ )} +
+ ); +} + +function DeleteJobButton({ jobId }: AdminButtonProps) { + const [formState, formAction] = useFormState(deleteJob, undefined); + + return ( +
+ + + Delete + + {formState?.error && ( +

{formState.error}

+ )} +
+ ); +} diff --git a/src/app/admin/jobs/[slug]/actions.ts b/src/app/admin/jobs/[slug]/actions.ts new file mode 100644 index 0000000..9f00957 --- /dev/null +++ b/src/app/admin/jobs/[slug]/actions.ts @@ -0,0 +1,75 @@ +"use server"; + +import prisma from "@/lib/prisma"; +import { isAdmin } from "@/lib/utils"; +import { currentUser } from "@clerk/nextjs"; +import { del } from "@vercel/blob"; +import { revalidatePath } from "next/cache"; +import { redirect } from "next/navigation"; + +type FormState = { error?: string } | undefined; + +export async function approveSubmission( + prevState: FormState, + formData: FormData, +): Promise { + try { + const jobId = parseInt(formData.get("jobId") as string); + + const user = await currentUser(); + + if (!user || !isAdmin(user)) { + throw new Error("Not authorized"); + } + + await prisma.job.update({ + where: { id: jobId }, + data: { approved: true }, + }); + + revalidatePath("/"); + } catch (error) { + let message = "Unexpected error"; + if (error instanceof Error) { + message = error.message; + } + return { error: message }; + } +} + +export async function deleteJob( + prevState: FormState, + formData: FormData, +): Promise { + try { + const jobId = parseInt(formData.get("jobId") as string); + + const user = await currentUser(); + + if (!user || !isAdmin(user)) { + throw new Error("Not authorized"); + } + + const job = await prisma.job.findUnique({ + where: { id: jobId }, + }); + + if (job?.companyLogoUrl) { + await del(job.companyLogoUrl); + } + + await prisma.job.delete({ + where: { id: jobId }, + }); + + revalidatePath("/"); + } catch (error) { + let message = "Unexpected error"; + if (error instanceof Error) { + message = error.message; + } + return { error: message }; + } + + redirect("/admin"); +} diff --git a/src/app/admin/jobs/[slug]/page.tsx b/src/app/admin/jobs/[slug]/page.tsx new file mode 100644 index 0000000..623656f --- /dev/null +++ b/src/app/admin/jobs/[slug]/page.tsx @@ -0,0 +1,23 @@ +import JobPage from "@/components/JobPage"; +import prisma from "@/lib/prisma"; +import { notFound } from "next/navigation"; +import AdminSidebar from "./AdminSidebar"; + +interface PageProps { + params: { slug: string }; +} + +export default async function Page({ params: { slug } }: PageProps) { + const job = await prisma.job.findUnique({ + where: { slug }, + }); + + if (!job) notFound(); + + return ( +
+ + +
+ ); +} diff --git a/src/app/admin/layout.tsx b/src/app/admin/layout.tsx new file mode 100644 index 0000000..7cb8400 --- /dev/null +++ b/src/app/admin/layout.tsx @@ -0,0 +1,16 @@ +import { ClerkProvider } from "@clerk/nextjs"; +import { Metadata } from "next"; +import AdminNavbar from "./AdminNavbar"; + +export const metadata: Metadata = { + title: "Admin", +}; + +export default function Layout({ children }: { children: React.ReactNode }) { + return ( + + + {children} + + ); +} diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx new file mode 100644 index 0000000..c0ac8c0 --- /dev/null +++ b/src/app/admin/page.tsx @@ -0,0 +1,27 @@ +import JobListItem from "@/components/JobListItem"; +import H1 from "@/components/ui/h1"; +import prisma from "@/lib/prisma"; +import Link from "next/link"; + +export default async function AdminPage() { + const unapprovedJobs = await prisma.job.findMany({ + where: { approved: false }, + }); + + return ( +
+

Admin Dashboard

+
+

Unapproved jobs:

+ {unapprovedJobs.map((job) => ( + + + + ))} + {unapprovedJobs.length === 0 && ( +

No unapproved jobs

+ )} +
+
+ ); +} diff --git a/src/app/error.tsx b/src/app/error.tsx new file mode 100644 index 0000000..275f9e6 --- /dev/null +++ b/src/app/error.tsx @@ -0,0 +1,12 @@ +"use client"; + +import H1 from "@/components/ui/h1"; + +export default function Error() { + return ( +
+

Error

+

An unexpected error occurred.

+
+ ); +} diff --git a/src/app/job-submitted/page.tsx b/src/app/job-submitted/page.tsx new file mode 100644 index 0000000..7071c47 --- /dev/null +++ b/src/app/job-submitted/page.tsx @@ -0,0 +1,10 @@ +import H1 from "@/components/ui/h1"; + +export default function Page() { + return ( +
+

Job submitted

+

Your job posting has been submitted and is pending approval.

+
+ ); +} diff --git a/src/app/jobs/[slug]/page.tsx b/src/app/jobs/[slug]/page.tsx new file mode 100644 index 0000000..5867547 --- /dev/null +++ b/src/app/jobs/[slug]/page.tsx @@ -0,0 +1,67 @@ +import JobPage from "@/components/JobPage"; +import { Button } from "@/components/ui/button"; +import prisma from "@/lib/prisma"; +import { Metadata } from "next"; +import { notFound } from "next/navigation"; +import { cache } from "react"; + +interface PageProps { + params: { slug: string }; +} + +const getJob = cache(async (slug: string) => { + const job = await prisma.job.findUnique({ + where: { slug }, + }); + + if (!job) notFound(); + + return job; +}); + +export async function generateStaticParams() { + const jobs = await prisma.job.findMany({ + where: { approved: true }, + select: { slug: true }, + }); + + return jobs.map(({ slug }) => slug); +} + +export async function generateMetadata({ + params: { slug }, +}: PageProps): Promise { + const job = await getJob(slug); + + return { + title: job.title, + }; +} + +export default async function Page({ params: { slug } }: PageProps) { + const job = await getJob(slug); + + const { applicationEmail, applicationUrl } = job; + + const applicationLink = applicationEmail + ? `mailto:${applicationEmail}` + : applicationUrl; + + if (!applicationLink) { + console.error("Job has no application link or email"); + notFound(); + } + + return ( +
+ + +
+ ); +} diff --git a/src/app/jobs/new/NewJobForm.tsx b/src/app/jobs/new/NewJobForm.tsx new file mode 100644 index 0000000..7ec4e6d --- /dev/null +++ b/src/app/jobs/new/NewJobForm.tsx @@ -0,0 +1,293 @@ +"use client"; + +import LoadingButton from "@/components/LoadingButton"; +import LocationInput from "@/components/LocationInput"; +import RichTextEditor from "@/components/RichTextEditor"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import H1 from "@/components/ui/h1"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import Select from "@/components/ui/select"; +import { jobTypes, locationTypes } from "@/lib/job-types"; +import { CreateJobValues, createJobSchema } from "@/lib/validation"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { X } from "lucide-react"; +import { draftToMarkdown } from "markdown-draft-js"; +import { useForm } from "react-hook-form"; +import { createJobPosting } from "./actions"; + +export default function NewJobForm() { + const form = useForm({ + resolver: zodResolver(createJobSchema), + }); + + const { + handleSubmit, + watch, + trigger, + control, + setValue, + setFocus, + formState: { isSubmitting }, + } = form; + + async function onSubmit(values: CreateJobValues) { + const formData = new FormData(); + + Object.entries(values).forEach(([key, value]) => { + if (value) { + formData.append(key, value); + } + }); + + try { + await createJobPosting(formData); + } catch (error) { + alert("Something went wrong, please try again."); + } + } + + return ( +
+
+

Find your perfect developer

+

+ Get your job posting seen by thousands of job seekers. +

+
+
+
+

Job details

+

+ Provide a job description and details +

+
+
+ + ( + + Job title + + + + + + )} + /> + ( + + Job type + + + + + + )} + /> + ( + + Company + + + + + + )} + /> + ( + + Company logo + + { + const file = e.target.files?.[0]; + fieldValues.onChange(file); + }} + /> + + + + )} + /> + ( + + Location + + + + + + )} + /> + ( + + Office location + + + + {watch("location") && ( +
+ + {watch("location")} +
+ )} + +
+ )} + /> +
+ +
+ ( + + +
+ + or +
+
+ +
+ )} + /> + ( + + + { + field.onChange(e); + trigger("applicationEmail"); + }} + /> + + + + )} + /> +
+
+ ( + + + + + field.onChange(draftToMarkdown(draft)) + } + ref={field.ref} + /> + + + + )} + /> + ( + + Salary + + + + + + )} + /> + + Submit + + + +
+
+ ); +} diff --git a/src/app/jobs/new/actions.ts b/src/app/jobs/new/actions.ts new file mode 100644 index 0000000..e0f4b57 --- /dev/null +++ b/src/app/jobs/new/actions.ts @@ -0,0 +1,61 @@ +"use server"; + +import prisma from "@/lib/prisma"; +import { toSlug } from "@/lib/utils"; +import { createJobSchema } from "@/lib/validation"; +import { put } from "@vercel/blob"; +import { nanoid } from "nanoid"; +import { redirect } from "next/navigation"; +import path from "path"; + +export async function createJobPosting(formData: FormData) { + const values = Object.fromEntries(formData.entries()); + + const { + title, + type, + companyName, + companyLogo, + locationType, + location, + applicationEmail, + applicationUrl, + description, + salary, + } = createJobSchema.parse(values); + + const slug = `${toSlug(title)}-${nanoid(10)}`; + + let companyLogoUrl: string | undefined = undefined; + + if (companyLogo) { + const blob = await put( + `company_logos/${slug}${path.extname(companyLogo.name)}`, + companyLogo, + { + access: "public", + addRandomSuffix: false, + }, + ); + + companyLogoUrl = blob.url; + } + + await prisma.job.create({ + data: { + slug, + title: title.trim(), + type, + companyName: companyName.trim(), + companyLogoUrl, + locationType, + location, + applicationEmail: applicationEmail?.trim(), + applicationUrl: applicationUrl?.trim(), + description: description?.trim(), + salary: parseInt(salary), + }, + }); + + redirect("/job-submitted"); +} diff --git a/src/app/jobs/new/page.tsx b/src/app/jobs/new/page.tsx new file mode 100644 index 0000000..7ebdac0 --- /dev/null +++ b/src/app/jobs/new/page.tsx @@ -0,0 +1,10 @@ +import { Metadata } from "next"; +import NewJobForm from "./NewJobForm"; + +export const metadata: Metadata = { + title: "Post a new job", +}; + +export default function Page() { + return ; +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 40e027f..962acea 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,22 +1,31 @@ -import type { Metadata } from 'next' -import { Inter } from 'next/font/google' -import './globals.css' +import Footer from "@/components/Footer"; +import Navbar from "@/components/Navbar"; +import type { Metadata } from "next"; +import { Inter } from "next/font/google"; +import "./globals.css"; -const inter = Inter({ subsets: ['latin'] }) +const inter = Inter({ subsets: ["latin"] }); export const metadata: Metadata = { - title: 'Create Next App', - description: 'Generated by create next app', -} + title: { + default: "Flow Jobs", + template: "%s | Flow Jobs", + }, + description: "Find your dream developer job.", +}; export default function RootLayout({ children, }: { - children: React.ReactNode + children: React.ReactNode; }) { return ( - {children} + + + {children} +