diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..172e059 --- /dev/null +++ b/.env.example @@ -0,0 +1,2 @@ +DIRECT_URL= +DATABASE_URL= \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..1b6457c --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,4 @@ +{ + "editor.formatOnSave": true, + "editor.defaultFormatter": "esbenp.prettier-vscode" +} diff --git a/README.md b/README.md index e215bc4..cad7c5e 100644 --- a/README.md +++ b/README.md @@ -1,36 +1,85 @@ -This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). +# Prisma Accelerate Hacker News Clone -## Getting Started +This project showcases how to use Prisma ORM with Prisma Accelerate, leveraging caching and on-demand cache invalidation, in a Next.js application to build a minimal Hacker News clone. -First, run the development server: +This app retrieves and caches the [top 20 latest posts](/app/page.tsx#L8) with a long Time-to-Live ([TTL](https://www.prisma.io/docs/accelerate/caching#time-to-live-ttl)). The cache is invalidated on-demand whenever a post is [upvoted](/app/actions/addVotes.ts) or a [new post is added](/app/submit/actions/addPost.ts). + +![GIF of interaction](demo.gif) + +## Prerequisites + +To successfully run the project, you will need the following: + +- The **connection string** of a PostgreSQL database +- Your **Accelerate connection string** (containing your **Accelerate API key**) which you can get by enabling Accelerate in a project in your [Prisma Data Platform](https://pris.ly/pdp) account (learn more in the [docs](https://www.prisma.io/docs/platform/concepts/environments#api-keys)) + +## Getting started + +### 1. Clone the repository + +Clone the repository, navigate into it and install dependencies: + +``` +git clone git@github.com:prisma/prisma-examples.git --depth=1 +cd prisma-examples/accelerate/accelerate-hacker-news +npm install +``` + +### 2. Configure environment variables + +Create a `.env` in the root of the project directory: ```bash -npm run dev -# or -yarn dev -# or -pnpm dev -# or -bun dev +cp .env.example .env ``` -Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. +Now, open the `.env` file and set the `DATABASE_URL` and `DIRECT_URL` environment variables with the values of your connection string and your Accelerate connection string: + +```bash +# .env -You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. +# Accelerate connection string (used for queries by Prisma Client) +DATABASE_URL="__YOUR_ACCELERATE_CONNECTION_STRING__" -This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. +# Database connection string (used for migrations by Prisma Migrate) +DIRECT_URL="__YOUR_DATABASE_CONNECTION_STRING__" +``` -## Learn More +Note that `__YOUR_DATABASE_CONNECTION_STRING__` and `__YOUR_ACCELERATE_CONNECTION_STRING__` are placeholder values that you need to replace with the values of your database and Accelerate connection strings. Notice that the Accelerate connection string has the following structure: `prisma://accelerate.prisma-data.net/?api_key=__YOUR_ACCELERATE_API_KEY__`. -To learn more about Next.js, take a look at the following resources: +### 3. Run a migration to create the `Post` table + +The Prisma schema file contains a single `Post` model. You can map this model to the database and create the corresponding `Post` table using the following command: + +``` +npx prisma migrate dev --name init +``` + +### 4. Generate Prisma Client for Accelerate + +When using Accelerate, Prisma Client doesn't need a query engine. That's why you should generate it as follows: + +``` +npx prisma generate --no-engine +``` + +### 5. Start the app + +You can run the app with the following command: + +``` +npm run dev +``` -- [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 should now be able to: -You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! +- See the most recent post at http://localhost:3000 and upvote it by clicking the ▲ button. +- Submit a new post by navigating to http://localhost:3000/submit. -## Deploy on Vercel +When you make changes, it might take a few seconds to invalidate the cache and display the latest changes. -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. +## Resources -Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. +- [Accelerate Speed Test](https://accelerate-speed-test.vercel.app/) +- [Accelerate documentation](https://www.prisma.io/docs/accelerate) +- [Prisma Discord](https://pris.ly/discord) \ No newline at end of file diff --git a/app/actions/clearCache.ts b/app/actions/clearCache.ts new file mode 100644 index 0000000..5a9d73d --- /dev/null +++ b/app/actions/clearCache.ts @@ -0,0 +1,12 @@ +"use server"; + +import prisma from "@/lib/db"; + +export async function clearCache() { + await prisma.$accelerate.invalidate({ + tags: ["posts"], + }); + + console.log("Cleared cached posts"); + return; +} diff --git a/app/page.tsx b/app/page.tsx index 1752a35..34ab048 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,39 +1,48 @@ import { Post } from "@/components/Posts"; import prisma from "@/lib/db"; -import Link from "next/link"; +import js_ago from "js-ago"; +import { clearCache } from "./actions/clearCache"; export default async function Home() { - const posts = await prisma.post.findMany({ - take: 20, - orderBy: { - createdAt: "desc", - }, - cacheStrategy: { - ttl: 120, - tags: ["posts"], - }, - }); + const { info, data } = await prisma.post + .findMany({ + take: 20, + orderBy: { + createdAt: "desc", + }, + cacheStrategy: { + ttl: 120, + tags: ["posts"], + }, + }) + .withAccelerateInfo(); - return ( - <> -
-
    - {posts.map((post, itemNo) => ( -
  1. - -
  2. - ))} -
-
- - ); + return ( + <> +
+
+ {info?.lastModified && ( +

Showing cached data from: {js_ago(info.lastModified)}

+ )} + +
+ +
+
+
    + {data.map((post, itemNo) => ( +
  1. + +
  2. + ))} +
+
+ + ); } diff --git a/app/submit/actions/addPost.ts b/app/submit/actions/addPost.ts index 1af33b9..a1ca269 100644 --- a/app/submit/actions/addPost.ts +++ b/app/submit/actions/addPost.ts @@ -1,24 +1,36 @@ -'use server' +"use server"; -import prisma from '@/lib/db' +import prisma from "@/lib/db"; +import { Filter } from "bad-words"; export async function createPost(formData: FormData) { - const title = formData.get('title') - const text = formData.get('text') - const url = formData.get('url') + const title = formData.get("title"); + const text = formData.get("text"); + const url = formData.get("url"); + + const filter = new Filter(); + + if ( + filter.isProfane(title?.toString() ?? "") || + filter.isProfane(text?.toString() ?? "") || + filter.isProfane(url?.toString() ?? "") + ) { + return false; + } const newPost = await prisma.post.create({ data: { - title: title?.toString() ?? '', - content: text?.toString() ?? '', - url: url?.toString() ?? '', + title: title?.toString() ?? "", + content: text?.toString() ?? "", + url: url?.toString() ?? "", vote: 0, }, - }) + }); await prisma.$accelerate.invalidate({ - tags: ['posts'], - }) + tags: ["posts"], + }); - console.log({ newPost }, 'has been created.') + console.log({ newPost }, "has been created."); + return true; } diff --git a/app/submit/actions/deletePost.ts b/app/submit/actions/deletePost.ts new file mode 100644 index 0000000..df93170 --- /dev/null +++ b/app/submit/actions/deletePost.ts @@ -0,0 +1,21 @@ +"use server"; + +import prisma from "@/lib/db"; + +export async function deletePost(id: number) { + console.log({ + deleting: id, + }); + + await prisma.post.delete({ + where: { + id: id, + }, + }); + + await prisma.$accelerate.invalidate({ + tags: ["posts"], + }); + + return id; +} diff --git a/components/PostSubmissionForm.tsx b/components/PostSubmissionForm.tsx index 3c90c7d..7161e80 100644 --- a/components/PostSubmissionForm.tsx +++ b/components/PostSubmissionForm.tsx @@ -1,16 +1,20 @@ -'use client' -import { createPost } from '@/app/submit/actions/addPost' -import { useRef } from 'react' +"use client"; +import { createPost } from "@/app/submit/actions/addPost"; +import { useRef } from "react"; +import toast, { Toaster } from "react-hot-toast"; export const PostForm = () => { - const ref = useRef(null) + const ref = useRef(null); return ( <>
{ - await createPost(formData) - ref.current?.reset() + const success = await createPost(formData); + if (!success) { + toast.error("Profanity is not cool."); + } + ref.current?.reset(); }} className="grid grid-cols-2 max-w-lg p-4 space-y-2 space-x-1" > @@ -64,6 +68,7 @@ export const PostForm = () => { Submit
+ - ) -} + ); +}; diff --git a/components/Posts.tsx b/components/Posts.tsx index a9ce09e..caf164f 100644 --- a/components/Posts.tsx +++ b/components/Posts.tsx @@ -1,29 +1,39 @@ -'use client' +"use client"; -import { addVotes } from '@/app/actions/addVotes' -import { useEffect, useState } from 'react' +import { addVotes } from "@/app/actions/addVotes"; +import { deletePost } from "@/app/submit/actions/deletePost"; +import { useEffect, useState } from "react"; interface PostProps { - id: number - itemNo: number - title: string - url: string - votes: number + id: number; + itemNo: number; + title: string; + url: string; + votes: number; } export const Post = ({ itemNo, title, url, votes, id }: PostProps) => { - useEffect(() => {}, [votes]) + useEffect(() => {}, [votes]); - const [vote, controlVote] = useState(votes) + const [vote, controlVote] = useState(votes); const increaseVotes = async () => { try { - await addVotes(id) - controlVote((prev) => prev + 1) + await addVotes(id); + controlVote((prev) => prev + 1); } catch (error) { - console.log(error) + console.log(error); } - } + }; + + const clearPost = async () => { + try { + await deletePost(id); + controlVote((prev) => prev + 1); + } catch (error) { + console.log(error); + } + }; return (
@@ -31,13 +41,18 @@ export const Post = ({ itemNo, title, url, votes, id }: PostProps) => { {`${itemNo}. `} {' '} - {title}{' '} + {" "} + {title}{" "} {`(${url})`}

-

{vote} points

+

+ {vote} points |{" "} + {" "} +

- ) -} + ); +}; diff --git a/package.json b/package.json index 6034a5f..d7c708c 100644 --- a/package.json +++ b/package.json @@ -1,34 +1,37 @@ { - "name": "accelerate-hacker-news", - "version": "0.1.0", - "private": true, - "scripts": { - "dev": "next dev", - "build": "next build", - "start": "next start", - "lint": "next lint" - }, - "dependencies": { - "@prisma/client": "^5.20.0", - "@prisma/extension-accelerate": "0.0.0-experimental-1584e8d", - "next": "14.2.13", - "react": "^18", - "react-dom": "^18" - }, - "devDependencies": { - "@faker-js/faker": "^9.0.2", - "@types/node": "^20", - "@types/react": "^18", - "@types/react-dom": "^18", - "eslint": "^8", - "eslint-config-next": "14.2.13", - "postcss": "^8", - "prisma": "^5.20.0", - "tailwindcss": "^3.4.1", - "tsx": "^4.19.1", - "typescript": "^5" - }, - "prisma": { - "seed": "tsx ./prisma/seed.ts" - } + "name": "accelerate-hacker-news", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "next lint" + }, + "dependencies": { + "@prisma/client": "^5.20.0", + "@prisma/extension-accelerate": "^1.2.0", + "bad-words": "^4.0.0", + "js-ago": "^2.1.1", + "next": "14.2.13", + "react": "^18", + "react-dom": "^18", + "react-hot-toast": "^2.4.1" + }, + "devDependencies": { + "@faker-js/faker": "^9.0.2", + "@types/node": "^20", + "@types/react": "^18", + "@types/react-dom": "^18", + "eslint": "^8", + "eslint-config-next": "14.2.13", + "postcss": "^8", + "prisma": "^5.20.0", + "tailwindcss": "^3.4.1", + "tsx": "^4.19.1", + "typescript": "^5" + }, + "prisma": { + "seed": "tsx ./prisma/seed.ts" + } } diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 9dc19c6..a913e56 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -5,7 +5,7 @@ generator client { datasource db { provider = "postgres" url = env("DATABASE_URL") - directUrl = env("DIRECT_DATABASE_URL") + directUrl = env("DIRECT_URL") } model Post {