Update MySQL example. (#34784)
Changes from https://github.com/planetscale/nextjs-starter. Realized the current example was not only out of date, but the demo was broken. Will update the demo site post-merge here 👍
1
examples/with-mysql/.env.example
Normal file
|
@ -0,0 +1 @@
|
|||
DATABASE_URL=mysql://<USERNAME>:<PLAIN_TEXT_PASSWORD>@<ACCESS_HOST_URL>/<DATABASE_NAME>?sslaccept=strict
|
|
@ -1,7 +0,0 @@
|
|||
# Example .env.local file for MySQL Database credentials
|
||||
|
||||
MYSQL_HOST=
|
||||
MYSQL_DATABASE=
|
||||
MYSQL_USERNAME=
|
||||
MYSQL_PASSWORD=
|
||||
MYSQL_PORT=
|
|
@ -1,82 +1,102 @@
|
|||
# MySQL Example
|
||||
# Next.js + MySQL
|
||||
|
||||
This is an example of using [MySQL](https://www.mysql.com/) in a Next.js project.
|
||||
This is a [Next.js](https://nextjs.org/) project that uses [Prisma](https://www.prisma.io/) to connect to a [PlanetScale](https://planetscale.com/) MySQL database and [Tailwind CSS](https://tailwindcss.com/) for styling.
|
||||
|
||||
## Demo
|
||||
|
||||
### [https://next-mysql.vercel.app](https://next-mysql.vercel.app/)
|
||||
https://next-mysql.vercel.app
|
||||
|
||||
## Deploy your own
|
||||
## Prerequisites
|
||||
|
||||
Once you have access to [the environment variables you'll need](#step-5-set-up-environment-variables), deploy the example using [Vercel](https://vercel.com?utm_source=github&utm_medium=readme&utm_campaign=next-example):
|
||||
- [Node.js](https://nodejs.org/en/download/)
|
||||
- [PlanetScale CLI](https://github.com/planetscale/cli)
|
||||
- Authenticate the CLI with the following command:
|
||||
|
||||
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/git/external?repository-url=https://github.com/vercel/next.js/tree/canary/examples/with-mysql&project-name=nextjs-mysql&repository-name=nextjs-mysql&env=MYSQL_HOST,MYSQL_DATABASE,MYSQL_USERNAME,MYSQL_PASSWORD&envDescription=Required%20to%20connect%20the%20app%20with%20MySQL&envLink=https%3A%2F%2Fgithub.com%2Fvercel%2Fnext.js%2Ftree%2Fcanary%2Fexamples%2Fwith-mysql%23step-2-set-up-environment-variables&demo-title=Next.js%20%2B%20MySQL%20Demo&demo-description=A%20simple%20app%20demonstrating%20Next.js%20and%20MySQL%20&demo-url=https%3A%2F%2Fnext-mysql.vercel.app%2F)
|
||||
```sh
|
||||
pscale auth login
|
||||
```
|
||||
|
||||
## How to use
|
||||
## Set up the database
|
||||
|
||||
Create a new database with the following command:
|
||||
|
||||
```sh
|
||||
pscale database create <DATABASE_NAME>
|
||||
```
|
||||
|
||||
> A branch, `main`, was automatically created when you created your database, so you can use that for `BRANCH_NAME` in the steps below.
|
||||
|
||||
## Set up the starter Next.js app
|
||||
|
||||
Execute [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app) with [npm](https://docs.npmjs.com/cli/init) or [Yarn](https://yarnpkg.com/lang/en/docs/cli/create/) to bootstrap the example:
|
||||
|
||||
```bash
|
||||
npx create-next-app --example with-mysql next-mysql-app
|
||||
npx create-next-app --example with-mysql nextjs-mysql
|
||||
# or
|
||||
yarn create next-app --example with-mysql next-mysql-app
|
||||
yarn create next-app --example with-mysql nextjs-mysql
|
||||
```
|
||||
|
||||
## Configuration
|
||||
Next, you'll need to create a database username and password through the CLI to connect to your application. If you'd prefer to use the dashboard for this step, you can find those instructions in the [Connection Strings documentation](https://docs.planetscale.com/concepts/connection-strings#creating-a-password) and then come back here to finish setup.
|
||||
|
||||
### Step 1. Set up a MySQL database
|
||||
First, create your `.env` file by renaming the `.env.example` file to `.env`:
|
||||
|
||||
Set up a MySQL server either locally or any cloud provider.
|
||||
|
||||
### Step 2. Set up environment variables
|
||||
|
||||
Copy the `env.local.example` file in this directory to `.env.local` (which will be ignored by Git):
|
||||
|
||||
```bash
|
||||
cp .env.local.example .env.local
|
||||
```sh
|
||||
mv .env.example .env
|
||||
```
|
||||
|
||||
Set each variable on `.env.local`:
|
||||
Next, using the PlanetScale CLI, create a new username and password for the branch of your database:
|
||||
|
||||
- `MYSQL_HOST` - Your MySQL host URL.
|
||||
- `MYSQL_DATABASE` - The name of the MySQL database you want to use.
|
||||
- `MYSQL_USERNAME` - The name of the MySQL user with access to database.
|
||||
- `MYSQL_PASSWORD` - The passowrd of the MySQL user.
|
||||
|
||||
### Step 3. Run migration script
|
||||
|
||||
You'll need to run a migration to create the necessary table for the example.
|
||||
|
||||
```bash
|
||||
npm run migrate
|
||||
# or
|
||||
yarn migrate
|
||||
```sh
|
||||
pscale password create <DATABASE_NAME> <BRANCH_NAME> <PASSWORD_NAME>
|
||||
```
|
||||
|
||||
### Step 4. Run Next.js in development mode
|
||||
> The `PASSWORD_NAME` value represents the name of the username and password being generated. You can have multiple credentials for a branch, so this gives you a way to categorize them. To manage your passwords in the dashboard, go to your database overview page, click "Settings", and then click "Passwords".
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev
|
||||
# or
|
||||
yarn install
|
||||
yarn dev
|
||||
Take note of the values returned to you, as you won't be able to see this password again.
|
||||
|
||||
```text
|
||||
Password production-password was successfully created.
|
||||
Please save the values below as they will not be shown again
|
||||
|
||||
NAME USERNAME ACCESS HOST URL ROLE PLAIN TEXT
|
||||
--------------------- -------------- ----------------------------------- ------------------ -------------------------------------------------------
|
||||
production-password xxxxxxxxxxxxx xxxxxx.us-east-2.psdb.cloud Can Read & Write pscale_pw_xxxxxxx
|
||||
```
|
||||
|
||||
Your app should be up and running on [http://localhost:3000](http://localhost:3000)! If it doesn't work, post on [GitHub discussions](https://github.com/vercel/next.js/discussions).
|
||||
You'll use these properties to construct your connection string, which will be the value for `DATABASE_URL` in your `.env` file. Update the `DATABASE_URL` property with your connection string in the following format:
|
||||
|
||||
## Deploy on Vercel
|
||||
```text
|
||||
mysql://<USERNAME>:<PLAIN_TEXT_PASSWORD>@<ACCESS_HOST_URL>/<DATABASE_NAME>?sslaccept=strict
|
||||
```
|
||||
|
||||
You can deploy this app to the cloud with [Vercel](https://vercel.com?utm_source=github&utm_medium=readme&utm_campaign=next-example) ([Documentation](https://nextjs.org/docs/deployment)).
|
||||
Push the database schema to your PlanetScale database using Prisma.
|
||||
|
||||
#### Deploy Your Local Project
|
||||
`npx prisma db push`
|
||||
|
||||
To deploy your local project to Vercel, push it to GitHub/GitLab/Bitbucket and [import to Vercel](https://vercel.com/new?utm_source=github&utm_medium=readme&utm_campaign=next-example).
|
||||
Run the seed script to populate your database with `Product` and `Category` data.
|
||||
|
||||
**Important**: When you import your project on Vercel, make sure to click on **Environment Variables** and set them to match your `.env.local` file.
|
||||
`npx run seed`
|
||||
|
||||
#### Deploy from Our Template
|
||||
## Run the App
|
||||
|
||||
Alternatively, you can deploy using our template by clicking on the Deploy button below.
|
||||
Run the app with following command:
|
||||
|
||||
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/git/external?repository-url=https://github.com/vercel/next.js/tree/canary/examples/with-mysql&project-name=nextjs-mysql&repository-name=nextjs-mysql&env=MYSQL_HOST,MYSQL_DATABASE,MYSQL_USERNAME,MYSQL_PASSWORD&envDescription=Required%20to%20connect%20the%20app%20with%20MySQL&envLink=https%3A%2F%2Fgithub.com%2Fvercel%2Fnext.js%2Ftree%2Fcanary%2Fexamples%2Fwith-mysql%23step-2-set-up-environment-variables&demo-title=Next.js%20%2B%20MySQL%20Demo&demo-description=A%20simple%20app%20demonstrating%20Next.js%20and%20MySQL%20&demo-url=https%3A%2F%2Fnext-mysql.vercel.app%2F)
|
||||
`npm run dev`
|
||||
|
||||
Open your browser at [localhost:3000](localhost:3000) to see the running application.
|
||||
|
||||
## Deploy your own
|
||||
|
||||
After you've got your application running locally, it's time to deploy it. To do so, you'll need to promote your database branch (`main` by default) to be the production branch ([read the branching documentation for more information](https://docs.planetscale.com/concepts/branching)).
|
||||
|
||||
```sh
|
||||
pscale branch promote <DATABASE_NAME> <BRANCH_NAME>
|
||||
```
|
||||
|
||||
Now that your branch has been promoted to production, you can either use the existing password you generated earlier for running locally or create a new password. Regardless, you'll need a password in the deployment steps below.
|
||||
|
||||
Deploy the example using [Vercel](https://vercel.com?utm_source=github&utm_medium=readme&utm_campaign=next-example):
|
||||
|
||||
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/git/external?repository-url=https://github.com/vercel/next.js/tree/canary/examples/with-mysql&project-name=with-mysql&repository-name=with-mysql&env=DATABASE_URL)
|
||||
|
||||
> Make sure to update the `DATABASE_URL` variable during this setup process.
|
||||
|
|
30
examples/with-mysql/components/Product.js
Normal file
|
@ -0,0 +1,30 @@
|
|||
import Image from 'next/image'
|
||||
|
||||
export default function Product({ product }) {
|
||||
const { name, description, price, image, category } = product
|
||||
|
||||
return (
|
||||
<div
|
||||
className="max-w-xs rounded overflow-hidden shadow-lg"
|
||||
key={product.id}
|
||||
>
|
||||
<Image
|
||||
className="w-full"
|
||||
width={320}
|
||||
height={160}
|
||||
src={image}
|
||||
alt={name}
|
||||
/>
|
||||
<div className="px-6 py-4">
|
||||
<div className="font-bold text-xl mb-2">{name}</div>
|
||||
<p className="text-gray-700 text-base">{description}</p>
|
||||
<p className="text-gray-900 text-xl">${price}</p>
|
||||
</div>
|
||||
<div className="px-6 pt-4 pb-2">
|
||||
<span className="inline-block bg-gray-200 rounded-full px-3 py-1 text-sm font-semibold text-gray-700 mr-2 mb-2">
|
||||
{category.name}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
|
@ -1,27 +0,0 @@
|
|||
import Link from 'next/link'
|
||||
import cn from 'clsx'
|
||||
|
||||
function ButtonLink({ href = '/', className = '', children }) {
|
||||
return (
|
||||
<Link href={href}>
|
||||
<a
|
||||
className={cn(
|
||||
'bg-black',
|
||||
'text-white',
|
||||
'p-2',
|
||||
'rounded',
|
||||
'uppercase',
|
||||
'text-sm',
|
||||
'font-bold',
|
||||
{
|
||||
[className]: Boolean(className),
|
||||
}
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
export default ButtonLink
|
|
@ -1,33 +0,0 @@
|
|||
import cn from 'clsx'
|
||||
|
||||
function Button({
|
||||
onClick = console.log,
|
||||
className = '',
|
||||
children = null,
|
||||
type = null,
|
||||
disabled = false,
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type={type}
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
'bg-black',
|
||||
'text-white',
|
||||
'p-2',
|
||||
'rounded',
|
||||
'uppercase',
|
||||
'text-sm',
|
||||
'font-bold',
|
||||
{
|
||||
[className]: Boolean(className),
|
||||
}
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export default Button
|
|
@ -1,5 +0,0 @@
|
|||
function Container({ className = '', children }) {
|
||||
return <div className={'container mx-auto px-8 ' + className}>{children}</div>
|
||||
}
|
||||
|
||||
export default Container
|
|
@ -1,78 +0,0 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import Router, { useRouter } from 'next/router'
|
||||
|
||||
import Button from '../button'
|
||||
|
||||
export default function EntryForm() {
|
||||
const [_title, setTitle] = useState('')
|
||||
const [_content, setContent] = useState('')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const router = useRouter()
|
||||
const { id, title, content } = router.query
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof title === 'string') {
|
||||
setTitle(title)
|
||||
}
|
||||
if (typeof content === 'string') {
|
||||
setContent(content)
|
||||
}
|
||||
}, [title, content])
|
||||
|
||||
async function submitHandler(e) {
|
||||
e.preventDefault()
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const res = await fetch('/api/edit-entry', {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
id,
|
||||
title: _title,
|
||||
content: _content,
|
||||
}),
|
||||
})
|
||||
const json = await res.json()
|
||||
setSubmitting(false)
|
||||
if (!res.ok) throw Error(json.message)
|
||||
Router.push('/')
|
||||
} catch (e) {
|
||||
throw Error(e.message)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={submitHandler}>
|
||||
<div className="my-4">
|
||||
<label htmlFor="title">
|
||||
<h3 className="font-bold">Title</h3>
|
||||
</label>
|
||||
<input
|
||||
id="title"
|
||||
className="shadow border rounded w-full"
|
||||
type="text"
|
||||
name="title"
|
||||
value={_title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="my-4">
|
||||
<label htmlFor="content">
|
||||
<h3 className="font-bold">Content</h3>
|
||||
</label>
|
||||
<textarea
|
||||
className="shadow border resize-none focus:shadow-outline w-full h-48"
|
||||
id="content"
|
||||
name="content"
|
||||
value={_content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Button disabled={submitting} type="submit">
|
||||
{submitting ? 'Saving ...' : 'Save'}
|
||||
</Button>
|
||||
</form>
|
||||
)
|
||||
}
|
|
@ -1,46 +0,0 @@
|
|||
import { useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { mutate } from 'swr'
|
||||
|
||||
import ButtonLink from '@/components/button-link'
|
||||
import Button from '@/components/button'
|
||||
|
||||
function Entry({ id, title, content }) {
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
|
||||
async function deleteEntry() {
|
||||
setDeleting(true)
|
||||
let res = await fetch(`/api/delete-entry?id=${id}`, { method: 'DELETE' })
|
||||
let json = await res.json()
|
||||
if (!res.ok) throw Error(json.message)
|
||||
mutate('/api/get-entries')
|
||||
setDeleting(false)
|
||||
}
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center">
|
||||
<Link href={`/entry/${id}`}>
|
||||
<a className="font-bold py-2">{title}</a>
|
||||
</Link>
|
||||
<div className="flex ml-4">
|
||||
<ButtonLink
|
||||
href={`/entry/edit/${id}?title=${title}&content=${content}`}
|
||||
className="h-5 py-0 mx-1"
|
||||
>
|
||||
Edit
|
||||
</ButtonLink>
|
||||
<Button
|
||||
disabled={deleting}
|
||||
onClick={deleteEntry}
|
||||
className="h-5 py-0 mx-1"
|
||||
>
|
||||
{deleting ? 'Deleting ...' : 'Delete'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<p>{content}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Entry
|
|
@ -1,19 +0,0 @@
|
|||
import Entry from './entry'
|
||||
|
||||
function Entries({ entries }) {
|
||||
if (entries) {
|
||||
return (
|
||||
<div>
|
||||
{entries.map((e) => (
|
||||
<div key={e.id} className="py-2">
|
||||
<Entry id={e.id} title={e.title} content={e.content} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export default Entries
|
|
@ -1,66 +0,0 @@
|
|||
import { useState } from 'react'
|
||||
import Router from 'next/router'
|
||||
|
||||
import Button from '@/components/button'
|
||||
|
||||
export default function EntryForm() {
|
||||
const [title, setTitle] = useState('')
|
||||
const [content, setContent] = useState('')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
async function submitHandler(e) {
|
||||
setSubmitting(true)
|
||||
e.preventDefault()
|
||||
try {
|
||||
const res = await fetch('/api/create-entry', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
title,
|
||||
content,
|
||||
}),
|
||||
})
|
||||
setSubmitting(false)
|
||||
const json = await res.json()
|
||||
if (!res.ok) throw Error(json.message)
|
||||
Router.push('/')
|
||||
} catch (e) {
|
||||
throw Error(e.message)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={submitHandler}>
|
||||
<div className="my-4">
|
||||
<label htmlFor="title">
|
||||
<h3 className="font-bold">Title</h3>
|
||||
</label>
|
||||
<input
|
||||
id="title"
|
||||
className="shadow border rounded w-full"
|
||||
type="text"
|
||||
name="title"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="my-4">
|
||||
<label htmlFor="content">
|
||||
<h3 className="font-bold">Content</h3>
|
||||
</label>
|
||||
<textarea
|
||||
className="shadow border resize-none focus:shadow-outline w-full h-48"
|
||||
id="content"
|
||||
name="content"
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Button disabled={submitting} type="submit">
|
||||
{submitting ? 'Creating ...' : 'Create'}
|
||||
</Button>
|
||||
</form>
|
||||
)
|
||||
}
|
|
@ -1,13 +0,0 @@
|
|||
import PoweredByVercel from '../powered-by-vercel'
|
||||
|
||||
function Footer() {
|
||||
return (
|
||||
<footer className="container mx-auto fixed bottom-0 right-0 left-0 flex justify-center items-center h-24">
|
||||
<a href="https://vercel.com?utm_source=next-mysql">
|
||||
<PoweredByVercel />
|
||||
</a>
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
|
||||
export default Footer
|
|
@ -1,18 +0,0 @@
|
|||
import Link from 'next/link'
|
||||
import Container from '@/components/container'
|
||||
import ButtonLink from '@/components/button-link'
|
||||
|
||||
export default function Nav({ title = 'Entries' }) {
|
||||
return (
|
||||
<Container className="py-4">
|
||||
<nav>
|
||||
<div className="flex justify-between items-center">
|
||||
<Link href="/">
|
||||
<a className="font-bold text-3xl">{title}</a>
|
||||
</Link>
|
||||
<ButtonLink href="/new">New Entry</ButtonLink>
|
||||
</div>
|
||||
</nav>
|
||||
</Container>
|
||||
)
|
||||
}
|
|
@ -1,24 +0,0 @@
|
|||
import mysql from 'serverless-mysql'
|
||||
|
||||
export const db = mysql({
|
||||
config: {
|
||||
host: process.env.MYSQL_HOST,
|
||||
database: process.env.MYSQL_DATABASE,
|
||||
user: process.env.MYSQL_USERNAME,
|
||||
password: process.env.MYSQL_PASSWORD,
|
||||
port: parseInt(process.env.MYSQL_PORT),
|
||||
},
|
||||
})
|
||||
|
||||
export async function query(
|
||||
q: string,
|
||||
values: (string | number)[] | string | number = []
|
||||
) {
|
||||
try {
|
||||
const results = await db.query(q, values)
|
||||
await db.end()
|
||||
return results
|
||||
} catch (e) {
|
||||
throw Error(e.message)
|
||||
}
|
||||
}
|
14
examples/with-mysql/lib/prisma.js
Normal file
|
@ -0,0 +1,14 @@
|
|||
import { PrismaClient } from '@prisma/client'
|
||||
|
||||
let prisma
|
||||
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
prisma = new PrismaClient()
|
||||
} else {
|
||||
if (!global.prisma) {
|
||||
global.prisma = new PrismaClient()
|
||||
}
|
||||
prisma = global.prisma
|
||||
}
|
||||
|
||||
export default prisma
|
|
@ -1,19 +0,0 @@
|
|||
import useSWR from 'swr'
|
||||
|
||||
function fetcher(url: string) {
|
||||
return window.fetch(url).then((res) => res.json())
|
||||
}
|
||||
|
||||
export function useEntries() {
|
||||
const { data, error } = useSWR(`/api/get-entries`, fetcher)
|
||||
|
||||
return {
|
||||
entries: data,
|
||||
isLoading: !error && !data,
|
||||
isError: error,
|
||||
}
|
||||
}
|
||||
|
||||
export function useEntry(id: string) {
|
||||
return useSWR(`/api/get-entry?id=${id}`, fetcher)
|
||||
}
|
5
examples/with-mysql/next-env.d.ts
vendored
|
@ -1,5 +0,0 @@
|
|||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/basic-features/typescript for more information.
|
3
examples/with-mysql/next.config.js
Normal file
|
@ -0,0 +1,3 @@
|
|||
module.exports = {
|
||||
reactStrictMode: true,
|
||||
}
|
|
@ -1,28 +1,21 @@
|
|||
{
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next",
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"migrate": "node scripts/migrate-db.js"
|
||||
"seed": "node prisma/seed.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"bad-words": "^3.0.4",
|
||||
"clsx": "^1.1.1",
|
||||
"dotenv": "^10.0.0",
|
||||
"@prisma/client": "3.10.0",
|
||||
"next": "latest",
|
||||
"react": "^17.0.2",
|
||||
"react-dom": "^17.0.2",
|
||||
"react-loading-skeleton": "^2.1.1",
|
||||
"serverless-mysql": "^1.5.4",
|
||||
"swr": "^0.3.9"
|
||||
"react": "17.0.2",
|
||||
"react-dom": "17.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^14.14.6",
|
||||
"@types/react": "^16.9.55",
|
||||
"postcss-flexbugs-fixes": "4.2.1",
|
||||
"postcss-preset-env": "^6.7.0",
|
||||
"tailwindcss": "^1.7.4",
|
||||
"typescript": "^4.0.5"
|
||||
"autoprefixer": "10.4.2",
|
||||
"postcss": "8.4.7",
|
||||
"prisma": "3.10.0",
|
||||
"tailwindcss": "3.0.23"
|
||||
}
|
||||
}
|
||||
|
|
5
examples/with-mysql/pages/_app.js
Normal file
|
@ -0,0 +1,5 @@
|
|||
import '../styles/globals.css'
|
||||
|
||||
export default function MyApp({ Component, pageProps }) {
|
||||
return <Component {...pageProps} />
|
||||
}
|
|
@ -1,13 +0,0 @@
|
|||
import '../styles/index.css'
|
||||
import Footer from '@/components/footer'
|
||||
|
||||
function MyApp({ Component, pageProps }) {
|
||||
return (
|
||||
<>
|
||||
<Component {...pageProps} />
|
||||
<Footer />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default MyApp
|
|
@ -1,30 +0,0 @@
|
|||
import { NextApiHandler } from 'next'
|
||||
import Filter from 'bad-words'
|
||||
import { query } from '../../lib/db'
|
||||
|
||||
const filter = new Filter()
|
||||
|
||||
const handler: NextApiHandler = async (req, res) => {
|
||||
const { title, content } = req.body
|
||||
try {
|
||||
if (!title || !content) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ message: '`title` and `content` are both required' })
|
||||
}
|
||||
|
||||
const results = await query(
|
||||
`
|
||||
INSERT INTO entries (title, content)
|
||||
VALUES (?, ?)
|
||||
`,
|
||||
[filter.clean(title), filter.clean(content)]
|
||||
)
|
||||
|
||||
return res.json(results)
|
||||
} catch (e) {
|
||||
res.status(500).json({ message: e.message })
|
||||
}
|
||||
}
|
||||
|
||||
export default handler
|
|
@ -1,26 +0,0 @@
|
|||
import { NextApiHandler } from 'next'
|
||||
import { query } from '../../lib/db'
|
||||
|
||||
const handler: NextApiHandler = async (req, res) => {
|
||||
const { id } = req.query
|
||||
try {
|
||||
if (!id) {
|
||||
return res.status(400).json({ message: '`id` required' })
|
||||
}
|
||||
if (typeof parseInt(id.toString()) !== 'number') {
|
||||
return res.status(400).json({ message: '`id` must be a number' })
|
||||
}
|
||||
const results = await query(
|
||||
`
|
||||
DELETE FROM entries
|
||||
WHERE id = ?
|
||||
`,
|
||||
id
|
||||
)
|
||||
res.json(results)
|
||||
} catch (e) {
|
||||
res.status(500).json({ message: e.message })
|
||||
}
|
||||
}
|
||||
|
||||
export default handler
|
|
@ -1,31 +0,0 @@
|
|||
import { NextApiHandler } from 'next'
|
||||
import Filter from 'bad-words'
|
||||
import { query } from '../../lib/db'
|
||||
|
||||
const filter = new Filter()
|
||||
|
||||
const handler: NextApiHandler = async (req, res) => {
|
||||
const { id, title, content } = req.body
|
||||
try {
|
||||
if (!id || !title || !content) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ message: '`id`,`title`, and `content` are all required' })
|
||||
}
|
||||
|
||||
const results = await query(
|
||||
`
|
||||
UPDATE entries
|
||||
SET title = ?, content = ?
|
||||
WHERE id = ?
|
||||
`,
|
||||
[filter.clean(title), filter.clean(content), id]
|
||||
)
|
||||
|
||||
return res.json(results)
|
||||
} catch (e) {
|
||||
res.status(500).json({ message: e.message })
|
||||
}
|
||||
}
|
||||
|
||||
export default handler
|
|
@ -1,18 +0,0 @@
|
|||
import { NextApiHandler } from 'next'
|
||||
import { query } from '../../lib/db'
|
||||
|
||||
const handler: NextApiHandler = async (_, res) => {
|
||||
try {
|
||||
const results = await query(`
|
||||
SELECT * FROM entries
|
||||
ORDER BY id DESC
|
||||
LIMIT 10
|
||||
`)
|
||||
|
||||
return res.json(results)
|
||||
} catch (e) {
|
||||
res.status(500).json({ message: e.message })
|
||||
}
|
||||
}
|
||||
|
||||
export default handler
|
|
@ -1,28 +0,0 @@
|
|||
import { NextApiHandler } from 'next'
|
||||
import { query } from '../../lib/db'
|
||||
|
||||
const handler: NextApiHandler = async (req, res) => {
|
||||
const { id } = req.query
|
||||
try {
|
||||
if (!id) {
|
||||
return res.status(400).json({ message: '`id` required' })
|
||||
}
|
||||
if (typeof parseInt(id.toString()) !== 'number') {
|
||||
return res.status(400).json({ message: '`id` must be a number' })
|
||||
}
|
||||
const results = await query(
|
||||
`
|
||||
SELECT id, title, content
|
||||
FROM entries
|
||||
WHERE id = ?
|
||||
`,
|
||||
id
|
||||
)
|
||||
|
||||
return res.json(results[0])
|
||||
} catch (e) {
|
||||
res.status(500).json({ message: e.message })
|
||||
}
|
||||
}
|
||||
|
||||
export default handler
|
16
examples/with-mysql/pages/api/products.js
Normal file
|
@ -0,0 +1,16 @@
|
|||
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
||||
import prisma from './../../lib/prisma.js'
|
||||
|
||||
export default async function handler(req, res) {
|
||||
if (req.method === 'GET') {
|
||||
try {
|
||||
const data = await prisma.product.findMany({})
|
||||
return res.status(200).json({ data })
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
return res.status(500).json({ msg: 'Something went wrong' })
|
||||
}
|
||||
} else {
|
||||
return res.status(405).json({ msg: 'Method not allowed' })
|
||||
}
|
||||
}
|
|
@ -1,33 +0,0 @@
|
|||
import { useRouter } from 'next/router'
|
||||
|
||||
import { useEntry } from '@/lib/swr-hooks'
|
||||
import Container from '@/components/container'
|
||||
import Nav from '@/components/nav'
|
||||
|
||||
export default function EditEntryPage() {
|
||||
const router = useRouter()
|
||||
const id = router.query.id?.toString()
|
||||
const { data } = useEntry(id)
|
||||
|
||||
if (data) {
|
||||
return (
|
||||
<>
|
||||
<Nav title="View" />
|
||||
<Container>
|
||||
<h1 className="font-bold text-3xl my-2">{data.title}</h1>
|
||||
<p>{data.content}</p>
|
||||
</Container>
|
||||
</>
|
||||
)
|
||||
} else {
|
||||
return (
|
||||
<>
|
||||
<Nav title="View" />
|
||||
<Container>
|
||||
<h1 className="font-bold text-3xl my-2">...</h1>
|
||||
<p>...</p>
|
||||
</Container>
|
||||
</>
|
||||
)
|
||||
}
|
||||
}
|
|
@ -1,14 +0,0 @@
|
|||
import Container from '@/components/container'
|
||||
import Nav from '@/components/nav'
|
||||
import EditEntryForm from '@/components/edit-entry-form'
|
||||
|
||||
export default function EditEntryPage() {
|
||||
return (
|
||||
<>
|
||||
<Nav title="Edit" />
|
||||
<Container>
|
||||
<EditEntryForm />
|
||||
</Container>
|
||||
</>
|
||||
)
|
||||
}
|
46
examples/with-mysql/pages/index.js
Normal file
|
@ -0,0 +1,46 @@
|
|||
import Head from 'next/head'
|
||||
import Product from '../components/Product'
|
||||
import prisma from '../lib/prisma'
|
||||
|
||||
export default function Home({ products }) {
|
||||
return (
|
||||
<div>
|
||||
<Head>
|
||||
<title>PlanetScale Next.js Quickstart</title>
|
||||
<meta name="description" content="PlanetScale Quickstart for Next.js" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
</Head>
|
||||
|
||||
<main className="p-10 mx-auto max-w-4xl">
|
||||
<h1 className="text-6xl font-bold mb-4 text-center">Next.js Starter</h1>
|
||||
<p className="mb-20 text-xl text-center">
|
||||
🔥 Shop from the hottest items in the world 🔥
|
||||
</p>
|
||||
<div className="grid md:grid-cols-3 sm:grid-cols-2 grid-cols-1 justify-items-center gap-4">
|
||||
{products.map((product) => (
|
||||
<Product product={product} key={product.id} />
|
||||
))}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer></footer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export async function getStaticProps(context) {
|
||||
const data = await prisma.product.findMany({
|
||||
include: {
|
||||
category: true,
|
||||
},
|
||||
})
|
||||
|
||||
//convert decimal value to string to pass through as json
|
||||
const products = data.map((product) => ({
|
||||
...product,
|
||||
price: product.price.toString(),
|
||||
}))
|
||||
return {
|
||||
props: { products },
|
||||
}
|
||||
}
|
|
@ -1,38 +0,0 @@
|
|||
import Skeleton from 'react-loading-skeleton'
|
||||
|
||||
import Nav from '@/components/nav'
|
||||
import Container from '@/components/container'
|
||||
import Entries from '@/components/entries'
|
||||
|
||||
import { useEntries } from '@/lib/swr-hooks'
|
||||
|
||||
export default function IndexPage() {
|
||||
const { entries, isLoading } = useEntries()
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div>
|
||||
<Nav />
|
||||
<Container>
|
||||
<Skeleton width={180} height={24} />
|
||||
<Skeleton height={48} />
|
||||
<div className="my-4" />
|
||||
<Skeleton width={180} height={24} />
|
||||
<Skeleton height={48} />
|
||||
<div className="my-4" />
|
||||
<Skeleton width={180} height={24} />
|
||||
<Skeleton height={48} />
|
||||
</Container>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Nav />
|
||||
<Container>
|
||||
<Entries entries={entries} />
|
||||
</Container>
|
||||
</div>
|
||||
)
|
||||
}
|
|
@ -1,14 +0,0 @@
|
|||
import Nav from '@/components/nav'
|
||||
import Container from '@/components/container'
|
||||
import EntryForm from '@/components/entry-form'
|
||||
|
||||
export default function NewEntryPage() {
|
||||
return (
|
||||
<>
|
||||
<Nav title="New" />
|
||||
<Container className="w-full lg:w-2/4">
|
||||
<EntryForm />
|
||||
</Container>
|
||||
</>
|
||||
)
|
||||
}
|
|
@ -1,18 +1,6 @@
|
|||
module.exports = {
|
||||
plugins: [
|
||||
'tailwindcss',
|
||||
'postcss-flexbugs-fixes',
|
||||
[
|
||||
'postcss-preset-env',
|
||||
{
|
||||
autoprefixer: {
|
||||
flexbox: 'no-2009',
|
||||
},
|
||||
stage: 3,
|
||||
features: {
|
||||
'custom-properties': false,
|
||||
},
|
||||
},
|
||||
],
|
||||
],
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
|
|
52
examples/with-mysql/prisma/data.js
Normal file
|
@ -0,0 +1,52 @@
|
|||
const { Prisma } = require('@prisma/client')
|
||||
|
||||
const categories = [
|
||||
{
|
||||
name: 'Hats',
|
||||
description: 'Things you can wear on your head',
|
||||
},
|
||||
{
|
||||
name: 'Socks',
|
||||
description: 'Things you can wear on your feet',
|
||||
},
|
||||
{
|
||||
name: 'Shirts',
|
||||
description: 'Things you wear on the top half of your body',
|
||||
},
|
||||
]
|
||||
|
||||
const products = [
|
||||
{
|
||||
name: 'Cool hat.',
|
||||
description: 'A nice hat to wear on your head',
|
||||
price: new Prisma.Decimal(19.95),
|
||||
image: '/images/placeholder.jpg',
|
||||
category_id: 1,
|
||||
},
|
||||
{
|
||||
name: 'Grey T-Shirt',
|
||||
description: 'A nice shirt that you can wear on your body',
|
||||
price: new Prisma.Decimal(22.95),
|
||||
image: '/images/placeholder.jpg',
|
||||
category_id: 3,
|
||||
},
|
||||
{
|
||||
name: 'Socks',
|
||||
description: 'Cool socks that you can wear on your feet',
|
||||
price: new Prisma.Decimal(12.95),
|
||||
image: '/images/placeholder.jpg',
|
||||
category_id: 2,
|
||||
},
|
||||
{
|
||||
name: 'Sweatshirt',
|
||||
description: 'Cool sweatshirt that you can wear on your body',
|
||||
price: new Prisma.Decimal(12.95),
|
||||
image: '/images/placeholder.jpg',
|
||||
category_id: 3,
|
||||
},
|
||||
]
|
||||
|
||||
module.exports = {
|
||||
products,
|
||||
categories,
|
||||
}
|
29
examples/with-mysql/prisma/schema.prisma
Normal file
|
@ -0,0 +1,29 @@
|
|||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
previewFeatures = ["referentialIntegrity"]
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "mysql"
|
||||
url = env("DATABASE_URL")
|
||||
referentialIntegrity = "prisma"
|
||||
}
|
||||
|
||||
model Product {
|
||||
id Int @id @default(autoincrement())
|
||||
name String
|
||||
description String
|
||||
price Decimal
|
||||
image String
|
||||
category Category? @relation(fields: [category_id], references: [id])
|
||||
category_id Int
|
||||
|
||||
@@index([category_id])
|
||||
}
|
||||
|
||||
model Category {
|
||||
id Int @id @default(autoincrement())
|
||||
name String
|
||||
description String
|
||||
products Product[]
|
||||
}
|
36
examples/with-mysql/prisma/seed.js
Normal file
|
@ -0,0 +1,36 @@
|
|||
const { PrismaClient } = require('@prisma/client')
|
||||
const { categories, products } = require('./data.js')
|
||||
const prisma = new PrismaClient()
|
||||
|
||||
const load = async () => {
|
||||
try {
|
||||
await prisma.category.deleteMany()
|
||||
console.log('Deleted records in category table')
|
||||
|
||||
await prisma.product.deleteMany()
|
||||
console.log('Deleted records in product table')
|
||||
|
||||
await prisma.$queryRaw`ALTER TABLE Product AUTO_INCREMENT = 1`
|
||||
console.log('reset product auto increment to 1')
|
||||
|
||||
await prisma.$queryRaw`ALTER TABLE Category AUTO_INCREMENT = 1`
|
||||
console.log('reset category auto increment to 1')
|
||||
|
||||
await prisma.category.createMany({
|
||||
data: categories,
|
||||
})
|
||||
console.log('Added category data')
|
||||
|
||||
await prisma.product.createMany({
|
||||
data: products,
|
||||
})
|
||||
console.log('Added product data')
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
process.exit(1)
|
||||
} finally {
|
||||
await prisma.$disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
load()
|
BIN
examples/with-mysql/public/favicon.ico
Normal file
After Width: | Height: | Size: 25 KiB |
BIN
examples/with-mysql/public/images/astronaut-suit.jpg
Normal file
After Width: | Height: | Size: 56 KiB |
BIN
examples/with-mysql/public/images/helmet.jpg
Normal file
After Width: | Height: | Size: 67 KiB |
BIN
examples/with-mysql/public/images/placeholder.jpg
Normal file
After Width: | Height: | Size: 38 KiB |
BIN
examples/with-mysql/public/images/shirt.jpg
Normal file
After Width: | Height: | Size: 81 KiB |
BIN
examples/with-mysql/public/images/socks.jpg
Normal file
After Width: | Height: | Size: 22 KiB |
BIN
examples/with-mysql/public/images/sweatshirt.jpg
Normal file
After Width: | Height: | Size: 69 KiB |
4
examples/with-mysql/public/vercel.svg
Normal file
|
@ -0,0 +1,4 @@
|
|||
<svg width="283" height="64" viewBox="0 0 283 64" fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M141.04 16c-11.04 0-19 7.2-19 18s8.96 18 20 18c6.67 0 12.55-2.64 16.19-7.09l-7.65-4.42c-2.02 2.21-5.09 3.5-8.54 3.5-4.79 0-8.86-2.5-10.37-6.5h28.02c.22-1.12.35-2.28.35-3.5 0-10.79-7.96-17.99-19-17.99zm-9.46 14.5c1.25-3.99 4.67-6.5 9.45-6.5 4.79 0 8.21 2.51 9.45 6.5h-18.9zM248.72 16c-11.04 0-19 7.2-19 18s8.96 18 20 18c6.67 0 12.55-2.64 16.19-7.09l-7.65-4.42c-2.02 2.21-5.09 3.5-8.54 3.5-4.79 0-8.86-2.5-10.37-6.5h28.02c.22-1.12.35-2.28.35-3.5 0-10.79-7.96-17.99-19-17.99zm-9.45 14.5c1.25-3.99 4.67-6.5 9.45-6.5 4.79 0 8.21 2.51 9.45 6.5h-18.9zM200.24 34c0 6 3.92 10 10 10 4.12 0 7.21-1.87 8.8-4.92l7.68 4.43c-3.18 5.3-9.14 8.49-16.48 8.49-11.05 0-19-7.2-19-18s7.96-18 19-18c7.34 0 13.29 3.19 16.48 8.49l-7.68 4.43c-1.59-3.05-4.68-4.92-8.8-4.92-6.07 0-10 4-10 10zm82.48-29v46h-9V5h9zM36.95 0L73.9 64H0L36.95 0zm92.38 5l-27.71 48L73.91 5H84.3l17.32 30 17.32-30h10.39zm58.91 12v9.69c-1-.29-2.06-.49-3.2-.49-5.81 0-10 4-10 10V51h-9V17h9v9.2c0-5.08 5.91-9.2 13.2-9.2z" fill="#000"/>
|
||||
</svg>
|
After Width: | Height: | Size: 1.1 KiB |
|
@ -1,53 +0,0 @@
|
|||
const path = require('path')
|
||||
const envPath = path.resolve(process.cwd(), '.env.local')
|
||||
|
||||
console.log({ envPath })
|
||||
|
||||
require('dotenv').config({ path: envPath })
|
||||
|
||||
const mysql = require('serverless-mysql')
|
||||
|
||||
const db = mysql({
|
||||
config: {
|
||||
host: process.env.MYSQL_HOST,
|
||||
database: process.env.MYSQL_DATABASE,
|
||||
user: process.env.MYSQL_USERNAME,
|
||||
password: process.env.MYSQL_PASSWORD,
|
||||
port: process.env.MYSQL_PORT,
|
||||
},
|
||||
})
|
||||
|
||||
async function query(q) {
|
||||
try {
|
||||
const results = await db.query(q)
|
||||
await db.end()
|
||||
return results
|
||||
} catch (e) {
|
||||
throw Error(e.message)
|
||||
}
|
||||
}
|
||||
|
||||
// Create "entries" table if doesn't exist
|
||||
async function migrate() {
|
||||
try {
|
||||
await query(`
|
||||
CREATE TABLE IF NOT EXISTS entries (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at
|
||||
TIMESTAMP
|
||||
NOT NULL
|
||||
DEFAULT CURRENT_TIMESTAMP
|
||||
ON UPDATE CURRENT_TIMESTAMP
|
||||
)
|
||||
`)
|
||||
console.log('migration ran successfully')
|
||||
} catch (e) {
|
||||
console.error('could not run migration, double check your credentials.')
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
migrate().then(() => process.exit())
|
3
examples/with-mysql/styles/globals.css
Normal file
|
@ -0,0 +1,3 @@
|
|||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
|
@ -1,18 +0,0 @@
|
|||
@tailwind base;
|
||||
|
||||
/* Write your own custom base styles here */
|
||||
|
||||
/* Start purging... */
|
||||
@tailwind components;
|
||||
/* Stop purging. */
|
||||
|
||||
/* Write your own custom component styles here */
|
||||
.btn-blue {
|
||||
@apply bg-blue-500 text-white font-bold py-2 px-4 rounded;
|
||||
}
|
||||
|
||||
/* Start purging... */
|
||||
@tailwind utilities;
|
||||
/* Stop purging. */
|
||||
|
||||
/* Your own custom utilities */
|
|
@ -1,16 +1,10 @@
|
|||
module.exports = {
|
||||
future: {
|
||||
removeDeprecatedGapUtilities: true,
|
||||
purgeLayersByDefault: true,
|
||||
},
|
||||
purge: ['./components/**/*.{js,ts,jsx,tsx}', './pages/**/*.{js,ts,jsx,tsx}'],
|
||||
content: [
|
||||
'./pages/**/*.{js,ts,jsx,tsx}',
|
||||
'./components/**/*.{js,ts,jsx,tsx}',
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
'accent-1': '#333',
|
||||
},
|
||||
},
|
||||
extend: {},
|
||||
},
|
||||
variants: {},
|
||||
plugins: [],
|
||||
}
|
||||
|
|
|
@ -1,24 +0,0 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/components/*": ["components/*"],
|
||||
"@/lib/*": ["lib/*"]
|
||||
},
|
||||
"target": "es5",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": false,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "node",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve"
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|