Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,5 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts

.env
37 changes: 5 additions & 32 deletions README.md
Original file line number Diff line number Diff line change
@@ -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)
12 changes: 10 additions & 2 deletions next.config.js
Original file line number Diff line number Diff line change
@@ -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;
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
33 changes: 33 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
@@ -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")
}
34 changes: 34 additions & 0 deletions src/app/admin/AdminNavbar.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="px-3">
<div className="m-auto flex h-10 max-w-5xl items-center justify-between gap-2">
<Link href="/admin" className="font-semibold underline">
Admin Dashboard
</Link>
<div className="space-x-2">
<span className="font-semibold">
{user?.primaryEmailAddress?.emailAddress}
</span>
<button
onClick={async () => {
await signOut();
router.push("/");
}}
className="underline"
>
Log out
</button>
</div>
</div>
</div>
);
}
61 changes: 61 additions & 0 deletions src/app/admin/jobs/[slug]/AdminSidebar.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<aside className="flex w-[200px] flex-none flex-row items-center gap-2 md:flex-col md:items-stretch">
{job.approved ? (
<span className="text-center font-semibold text-green-500">
Approved
</span>
) : (
<ApproveSubmissionButton jobId={job.id} />
)}
<DeleteJobButton jobId={job.id} />
</aside>
);
}

interface AdminButtonProps {
jobId: number;
}

function ApproveSubmissionButton({ jobId }: AdminButtonProps) {
const [formState, formAction] = useFormState(approveSubmission, undefined);

return (
<form action={formAction} className="space-y-1">
<input hidden name="jobId" value={jobId} />
<FormSubmitButton className="w-full bg-green-500 hover:bg-green-600">
Approve
</FormSubmitButton>
{formState?.error && (
<p className="text-sm text-red-500">{formState.error}</p>
)}
</form>
);
}

function DeleteJobButton({ jobId }: AdminButtonProps) {
const [formState, formAction] = useFormState(deleteJob, undefined);

return (
<form action={formAction} className="space-y-1">
<input hidden name="jobId" value={jobId} />
<FormSubmitButton className="w-full bg-red-500 hover:bg-red-600">
Delete
</FormSubmitButton>
{formState?.error && (
<p className="text-sm text-red-500">{formState.error}</p>
)}
</form>
);
}
75 changes: 75 additions & 0 deletions src/app/admin/jobs/[slug]/actions.ts
Original file line number Diff line number Diff line change
@@ -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<FormState> {
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<FormState> {
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");
}
23 changes: 23 additions & 0 deletions src/app/admin/jobs/[slug]/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<main className="m-auto my-10 flex max-w-5xl flex-col items-center gap-5 px-3 md:flex-row md:items-start">
<JobPage job={job} />
<AdminSidebar job={job} />
</main>
);
}
16 changes: 16 additions & 0 deletions src/app/admin/layout.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<ClerkProvider>
<AdminNavbar />
{children}
</ClerkProvider>
);
}
27 changes: 27 additions & 0 deletions src/app/admin/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<main className="m-auto my-10 max-w-5xl space-y-10 px-3">
<H1 className="text-center">Admin Dashboard</H1>
<section className="flex flex-col gap-3">
<h2 className="text-lg font-bold">Unapproved jobs:</h2>
{unapprovedJobs.map((job) => (
<Link key={job.id} href={`/admin/jobs/${job.slug}`} className="block">
<JobListItem job={job} />
</Link>
))}
{unapprovedJobs.length === 0 && (
<p className="text-muted-foreground">No unapproved jobs</p>
)}
</section>
</main>
);
}
12 changes: 12 additions & 0 deletions src/app/error.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
"use client";

import H1 from "@/components/ui/h1";

export default function Error() {
return (
<main className="m-auto my-10 max-w-5xl space-y-5 px-3 text-center">
<H1>Error</H1>
<p>An unexpected error occurred.</p>
</main>
);
}
10 changes: 10 additions & 0 deletions src/app/job-submitted/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import H1 from "@/components/ui/h1";

export default function Page() {
return (
<main className="m-auto my-10 max-w-5xl space-y-5 px-3 text-center">
<H1>Job submitted</H1>
<p>Your job posting has been submitted and is pending approval.</p>
</main>
);
}
Loading