From c2e5f0fc699c9563823b4a6e2eb285f69d87e003 Mon Sep 17 00:00:00 2001
From: shadcn
Date: Wed, 26 Oct 2022 17:18:06 +0400
Subject: [PATCH] feat: initial commit
---
.env.example | 23 +
.eslintrc.json | 6 +
.gitignore | 39 +
.nvmrc | 1 +
LICENSE.md | 21 +
README.md | 76 +
app/(auth)/layout.tsx | 9 +
app/(auth)/login/page.tsx | 37 +
app/(auth)/register/page.tsx | 41 +
app/(dashboard)/dashboard/layout.tsx | 54 +
app/(dashboard)/dashboard/loading.tsx | 21 +
app/(dashboard)/dashboard/page.tsx | 60 +
.../dashboard/settings/loading.tsx | 18 +
app/(dashboard)/dashboard/settings/page.tsx | 40 +
app/(editor)/editor/[postId]/not-found.tsx | 21 +
app/(editor)/editor/[postId]/page.tsx | 38 +
app/(editor)/editor/layout.tsx | 15 +
app/(marketing)/layout.tsx | 23 +
app/(marketing)/page.tsx | 33 +
app/head.tsx | 9 +
app/layout.tsx | 18 +
components/dashboard-branding.tsx | 10 +
components/dashboard-header.tsx | 23 +
components/dashboard-nav.tsx | 65 +
components/dashboard-shell.tsx | 16 +
components/editor.tsx | 164 +
components/empty-placeholder.tsx | 79 +
components/icons.tsx | 34 +
components/post-create-button.tsx | 69 +
components/post-item.tsx | 42 +
components/post-operations.tsx | 98 +
components/ui/alert.tsx | 112 +
components/ui/avatar.tsx | 40 +
components/ui/card.tsx | 66 +
components/ui/dropdown.tsx | 67 +
components/ui/toast.tsx | 89 +
components/user-account-nav.tsx | 77 +
components/user-auth-form.tsx | 120 +
components/user-avatar.tsx | 21 +
components/user-name-form.tsx | 116 +
lib/api-middlewares/with-authentication.ts | 14 +
lib/api-middlewares/with-methods.ts | 11 +
lib/api-middlewares/with-post.ts | 37 +
lib/api-middlewares/with-user.ts | 30 +
lib/api-middlewares/with-validation.ts | 24 +
lib/db.ts | 18 +
lib/models.ts | 7 +
lib/session.ts | 15 +
lib/utils.ts | 15 +
lib/validations/auth.ts | 5 +
lib/validations/post.ts | 8 +
lib/validations/user.ts | 5 +
middleware.ts | 22 +
next.config.js | 14 +
package.json | 68 +
pages/.gitkeep | 0
pages/api/auth/[...nextauth].ts | 77 +
pages/api/posts/[postId].ts | 62 +
pages/api/posts/index.ts | 64 +
pages/api/users/[userId].ts | 43 +
postcss.config.js | 6 +
.../20221021182747_init/migration.sql | 77 +
prisma/migrations/migration_lock.toml | 3 +
prisma/schema.prisma | 84 +
public/favicon.ico | Bin 0 -> 25931 bytes
public/images/nextjs-icon-dark-background.png | Bin 0 -> 36630 bytes
public/vercel.svg | 4 +
styles/globals.css | 3 +
tailwind.config.js | 27 +
tsconfig.json | 48 +
types/next-auth.d.ts | 9 +
yarn.lock | 3124 +++++++++++++++++
72 files changed, 5835 insertions(+)
create mode 100644 .env.example
create mode 100644 .eslintrc.json
create mode 100644 .gitignore
create mode 100644 .nvmrc
create mode 100644 LICENSE.md
create mode 100644 README.md
create mode 100644 app/(auth)/layout.tsx
create mode 100644 app/(auth)/login/page.tsx
create mode 100644 app/(auth)/register/page.tsx
create mode 100644 app/(dashboard)/dashboard/layout.tsx
create mode 100644 app/(dashboard)/dashboard/loading.tsx
create mode 100644 app/(dashboard)/dashboard/page.tsx
create mode 100644 app/(dashboard)/dashboard/settings/loading.tsx
create mode 100644 app/(dashboard)/dashboard/settings/page.tsx
create mode 100644 app/(editor)/editor/[postId]/not-found.tsx
create mode 100644 app/(editor)/editor/[postId]/page.tsx
create mode 100644 app/(editor)/editor/layout.tsx
create mode 100644 app/(marketing)/layout.tsx
create mode 100644 app/(marketing)/page.tsx
create mode 100644 app/head.tsx
create mode 100644 app/layout.tsx
create mode 100644 components/dashboard-branding.tsx
create mode 100644 components/dashboard-header.tsx
create mode 100644 components/dashboard-nav.tsx
create mode 100644 components/dashboard-shell.tsx
create mode 100644 components/editor.tsx
create mode 100644 components/empty-placeholder.tsx
create mode 100644 components/icons.tsx
create mode 100644 components/post-create-button.tsx
create mode 100644 components/post-item.tsx
create mode 100644 components/post-operations.tsx
create mode 100644 components/ui/alert.tsx
create mode 100644 components/ui/avatar.tsx
create mode 100644 components/ui/card.tsx
create mode 100644 components/ui/dropdown.tsx
create mode 100644 components/ui/toast.tsx
create mode 100644 components/user-account-nav.tsx
create mode 100644 components/user-auth-form.tsx
create mode 100644 components/user-avatar.tsx
create mode 100644 components/user-name-form.tsx
create mode 100644 lib/api-middlewares/with-authentication.ts
create mode 100644 lib/api-middlewares/with-methods.ts
create mode 100644 lib/api-middlewares/with-post.ts
create mode 100644 lib/api-middlewares/with-user.ts
create mode 100644 lib/api-middlewares/with-validation.ts
create mode 100644 lib/db.ts
create mode 100644 lib/models.ts
create mode 100644 lib/session.ts
create mode 100644 lib/utils.ts
create mode 100644 lib/validations/auth.ts
create mode 100644 lib/validations/post.ts
create mode 100644 lib/validations/user.ts
create mode 100644 middleware.ts
create mode 100644 next.config.js
create mode 100644 package.json
create mode 100644 pages/.gitkeep
create mode 100644 pages/api/auth/[...nextauth].ts
create mode 100644 pages/api/posts/[postId].ts
create mode 100644 pages/api/posts/index.ts
create mode 100644 pages/api/users/[userId].ts
create mode 100644 postcss.config.js
create mode 100644 prisma/migrations/20221021182747_init/migration.sql
create mode 100644 prisma/migrations/migration_lock.toml
create mode 100644 prisma/schema.prisma
create mode 100644 public/favicon.ico
create mode 100644 public/images/nextjs-icon-dark-background.png
create mode 100644 public/vercel.svg
create mode 100644 styles/globals.css
create mode 100644 tailwind.config.js
create mode 100644 tsconfig.json
create mode 100644 types/next-auth.d.ts
create mode 100644 yarn.lock
diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..f6d545b
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,23 @@
+# -----------------------------------------------------------------------------
+# Authentication (NextAuth.js)
+# -----------------------------------------------------------------------------
+NEXTAUTH_URL=http://localhost:3000
+NEXTAUTH_SECRET=
+
+GITHUB_CLIENT_ID=
+GITHUB_CLIENT_SECRET=
+
+# -----------------------------------------------------------------------------
+# Database (MySQL - PlanetScale)
+# -----------------------------------------------------------------------------
+DATABASE_URL="mysql://root:root@localhost:3306/taxonomy?schema=public"
+
+# -----------------------------------------------------------------------------
+# Email (Postmark)
+# -----------------------------------------------------------------------------
+POSTMARK_API_TOKEN=
+SMTP_HOST=smtp.postmarkapp.com
+SMTP_PORT=25
+SMTP_USER=
+SMTP_PASSWORD=
+SMTP_FROM=Taxonomy
\ No newline at end of file
diff --git a/.eslintrc.json b/.eslintrc.json
new file mode 100644
index 0000000..0240716
--- /dev/null
+++ b/.eslintrc.json
@@ -0,0 +1,6 @@
+{
+ "extends": "next/core-web-vitals",
+ "rules": {
+ "@next/next/no-head-element": "off"
+ }
+}
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..356c143
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,39 @@
+# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
+
+# dependencies
+/node_modules
+/.pnp
+.pnp.js
+
+# testing
+/coverage
+
+# next.js
+/.next/
+/out/
+
+# production
+/build
+
+# misc
+.DS_Store
+*.pem
+
+# debug
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+.pnpm-debug.log*
+
+# local env files
+.env*.local
+.env
+
+# vercel
+.vercel
+
+# typescript
+*.tsbuildinfo
+next-env.d.ts
+
+.vscode
diff --git a/.nvmrc b/.nvmrc
new file mode 100644
index 0000000..a5e323e
--- /dev/null
+++ b/.nvmrc
@@ -0,0 +1 @@
+v14.17.3
diff --git a/LICENSE.md b/LICENSE.md
new file mode 100644
index 0000000..e307893
--- /dev/null
+++ b/LICENSE.md
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2022 shadcn
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..c9bed0e
--- /dev/null
+++ b/README.md
@@ -0,0 +1,76 @@
+
+
+
+
Taxonomy
+ An open source application built using the new router, server components and everything new in Next.js 13.
+
+
+> This app is a work in progress. I'm building this in public. You can follow the progress on Twitter [@shadcn](https://twitter.com).
+
+## Demo
+
+See https://www.youtube.com/watch?v=G5vCj8wWkuc
+
+## Features
+
+- New `/app` dir,
+- Routing, Layouts, Nested Layouts and Layout Groups.
+- Data fetching, Caching and Mutation.
+- Loading UI,
+- Server and Client Components.
+- API Routes and Middlewares.
+- Authentication using **NextAuth.js**.
+- ORM using **Prisma**.
+- UI Components built using **Radix UI**.
+- Styled using **Tailwind CSS**.
+- Validations using **Zod**.
+- Written in **TypeScript**.
+
+## Roadmap
+
+> This app is a work in progress. I'm building this in public. You can follow the progress on Twitter [@shadcn](https://twitter.com).
+
+- Responsive styles.
+- Subscriptions using Stripe.
+- Add Media Library.
+- Add Pages.
+- Build the front-end for blogs.
+- Add support for custom domains for blogs.
+- Build marketing pages (use a headless CMS?)
+- Add MDX support for basic pages.
+- Add OG image for blog using @vercel/og.
+- Dark mode.
+
+## Issues
+
+A list of things not working for now:
+
+1. GitHub authentication (use email)
+2. NextAuth.js middleware (getSession not updated to work with Next.js 13)
+3. Returning `notFound()` is resulting in a linting error.
+
+## Why not trpc, Turborepo, pnpm or X?
+
+I might add this later. For now, I want to see how far we can get using Next.js only.
+
+If you have some suggestions, feel free to create an issue.
+
+## Running Locally
+
+1. Install dependencies using Yarn:
+
+```sh
+yarn
+```
+
+2. Copy `.env.example` to `.env.local` and update the variables.
+
+3. Start the development server:
+
+```sh
+yarn dev
+```
+
+## License
+
+Licensed under the [MIT license](https://github.com/reflexjs/reflex/blob/master/LICENSE).
diff --git a/app/(auth)/layout.tsx b/app/(auth)/layout.tsx
new file mode 100644
index 0000000..c0b534d
--- /dev/null
+++ b/app/(auth)/layout.tsx
@@ -0,0 +1,9 @@
+import "styles/globals.css"
+
+interface AuthLayoutProps {
+ children: React.ReactNode
+}
+
+export default function RootLayout({ children }: AuthLayoutProps) {
+ return {children}
+}
diff --git a/app/(auth)/login/page.tsx b/app/(auth)/login/page.tsx
new file mode 100644
index 0000000..a3da97c
--- /dev/null
+++ b/app/(auth)/login/page.tsx
@@ -0,0 +1,37 @@
+import Link from "next/link"
+
+import { Icons } from "@/components/icons"
+import { UserAuthForm } from "@/components/user-auth-form"
+
+export default function LoginPage() {
+ return (
+
+
+ <>
+
+ Back
+ >
+
+
+
+
+
+
Welcome back
+
+ Enter your email to sign in to your account
+
+
+
+
+
+ Don't have an account? Sign Up
+
+
+
+
+
+ )
+}
diff --git a/app/(auth)/register/page.tsx b/app/(auth)/register/page.tsx
new file mode 100644
index 0000000..30898b7
--- /dev/null
+++ b/app/(auth)/register/page.tsx
@@ -0,0 +1,41 @@
+import Link from "next/link"
+
+import { Icons } from "@/components/icons"
+import { UserAuthForm } from "@/components/user-auth-form"
+
+export default function RegisterPage() {
+ return (
+
+
+ Login
+
+
+
+
+
+
+
Create an account
+
+ Enter your email below to create your account
+
+
+
+
+ By clicking continue, you agree to our{" "}
+
+ Terms of Service
+ {" "}
+ and{" "}
+
+ Privacy Policy
+
+ .
+
+
+
+
+ )
+}
diff --git a/app/(dashboard)/dashboard/layout.tsx b/app/(dashboard)/dashboard/layout.tsx
new file mode 100644
index 0000000..15f21b2
--- /dev/null
+++ b/app/(dashboard)/dashboard/layout.tsx
@@ -0,0 +1,54 @@
+import { headers } from "next/headers"
+
+import { getSession } from "@/lib/session"
+import { DashboardBranding } from "@/components/dashboard-branding"
+import { DashboardNav } from "@/components/dashboard-nav"
+import { UserAccountNav } from "@/components/user-account-nav"
+import { notFound } from "next/navigation"
+
+interface DashboardLayoutProps {
+ children?: React.ReactNode
+}
+
+async function getUser() {
+ const session = await getSession(headers().get("cookie"))
+
+ if (!session?.user) {
+ return null
+ }
+
+ return session.user
+}
+
+export default async function DashboardLayout({
+ children,
+}: DashboardLayoutProps) {
+ const user = await getUser()
+
+ if (!user) {
+ return notFound()
+ }
+
+ return (
+ <>
+
+ >
+ )
+}
diff --git a/app/(dashboard)/dashboard/loading.tsx b/app/(dashboard)/dashboard/loading.tsx
new file mode 100644
index 0000000..50a8aa0
--- /dev/null
+++ b/app/(dashboard)/dashboard/loading.tsx
@@ -0,0 +1,21 @@
+import { DashboardHeader } from "@/components/dashboard-header"
+import { DashboardShell } from "@/components/dashboard-shell"
+import { PostCreateButton } from "@/components/post-create-button"
+import { PostItem } from "@/components/post-item"
+
+export default function DashboardLoading() {
+ return (
+
+
+
+
+
+
+ )
+}
diff --git a/app/(dashboard)/dashboard/page.tsx b/app/(dashboard)/dashboard/page.tsx
new file mode 100644
index 0000000..d588f70
--- /dev/null
+++ b/app/(dashboard)/dashboard/page.tsx
@@ -0,0 +1,60 @@
+import { headers } from "next/headers"
+
+import { db } from "@/lib/db"
+import { getSession } from "@/lib/session"
+import { DashboardHeader } from "@/components/dashboard-header"
+import { PostCreateButton } from "@/components/post-create-button"
+import { DashboardShell } from "@/components/dashboard-shell"
+import { PostItem } from "@/components/post-item"
+import { EmptyPlaceholder } from "@/components/empty-placeholder"
+
+export const dynamic = "force-dynamic"
+
+async function getPosts() {
+ const session = await getSession(headers().get("cookie"))
+
+ return await db.post.findMany({
+ where: {
+ authorId: session?.user.id,
+ },
+ select: {
+ id: true,
+ title: true,
+ published: true,
+ createdAt: true,
+ },
+ orderBy: {
+ updatedAt: "desc",
+ },
+ })
+}
+
+export default async function DashboardPage() {
+ const posts = await getPosts()
+
+ return (
+
+
+
+
+
+ {posts?.length ? (
+
+ {posts.map((post) => (
+
+ ))}
+
+ ) : (
+
+
+ No posts created
+
+ You don't have any posts yet. Start creating content.
+
+
+
+ )}
+
+
+ )
+}
diff --git a/app/(dashboard)/dashboard/settings/loading.tsx b/app/(dashboard)/dashboard/settings/loading.tsx
new file mode 100644
index 0000000..911d27b
--- /dev/null
+++ b/app/(dashboard)/dashboard/settings/loading.tsx
@@ -0,0 +1,18 @@
+import { DashboardHeader } from "@/components/dashboard-header"
+import { DashboardShell } from "@/components/dashboard-shell"
+import { Card } from "@/components/ui/card"
+
+export default function DashboardSettingsLoading() {
+ return (
+
+
+
+
+
+
+
+ )
+}
diff --git a/app/(dashboard)/dashboard/settings/page.tsx b/app/(dashboard)/dashboard/settings/page.tsx
new file mode 100644
index 0000000..90fa78c
--- /dev/null
+++ b/app/(dashboard)/dashboard/settings/page.tsx
@@ -0,0 +1,40 @@
+import { headers } from "next/headers"
+// import { notFound } from "next/dist/client/components/not-found"
+
+import { getSession } from "@/lib/session"
+import { DashboardHeader } from "@/components/dashboard-header"
+import { DashboardShell } from "@/components/dashboard-shell"
+import { UserNameForm } from "@/components/user-name-form"
+
+async function getUser() {
+ const session = await getSession(headers().get("cookie"))
+
+ if (!session?.user) {
+ return null
+ }
+
+ return session.user
+}
+
+export default async function SettingsPage() {
+ const user = await getUser()
+
+ // TODO: If I return notFound here, I get a linting error.
+ // Type error: Page "app/(dashboard)/dashboard/settings/page.tsx" does not match the required types of a Next.js Page.
+ // Expected "ReactNode", got "void | Element".
+ // if (!user) {
+ // return notFound()
+ // }
+
+ return (
+
+
+
+
+
+
+ )
+}
diff --git a/app/(editor)/editor/[postId]/not-found.tsx b/app/(editor)/editor/[postId]/not-found.tsx
new file mode 100644
index 0000000..a08bd8a
--- /dev/null
+++ b/app/(editor)/editor/[postId]/not-found.tsx
@@ -0,0 +1,21 @@
+import Link from "next/link"
+
+import { EmptyPlaceholder } from "@/components/empty-placeholder"
+
+export default function NotFound() {
+ return (
+
+
+ Uh oh! Not Found
+
+ This post cound not be found. Please try again.
+
+
+ Go to Dashboard
+
+
+ )
+}
diff --git a/app/(editor)/editor/[postId]/page.tsx b/app/(editor)/editor/[postId]/page.tsx
new file mode 100644
index 0000000..6c4d033
--- /dev/null
+++ b/app/(editor)/editor/[postId]/page.tsx
@@ -0,0 +1,38 @@
+// import { notFound } from "next/dist/client/components/not-found"
+
+import { Editor } from "@/components/editor"
+import { db } from "@/lib/db"
+
+async function getPost(postId: string) {
+ return await db.post.findFirst({
+ where: {
+ id: postId,
+ },
+ })
+}
+
+interface EditorPageProps {
+ params: { postId: string }
+}
+
+export default async function EditorPage({ params }: EditorPageProps) {
+ const post = await getPost(params.postId)
+
+ // TODO: If I return notFound here, I get a linting error.
+ // Type error: Page "app/(editor)/editor/[postId]/page.tsx" does not match the required types of a Next.js Page.
+ // Expected "ReactNode", got "void | Element".
+ // if (!post) {
+ // return notFound()
+ // }
+
+ return (
+
+ )
+}
diff --git a/app/(editor)/editor/layout.tsx b/app/(editor)/editor/layout.tsx
new file mode 100644
index 0000000..7a97a27
--- /dev/null
+++ b/app/(editor)/editor/layout.tsx
@@ -0,0 +1,15 @@
+import Link from "next/link"
+
+import { Icons } from "@/components/icons"
+
+interface EditorProps {
+ children?: React.ReactNode
+}
+
+export default function EditorLayout({ children }: EditorProps) {
+ return (
+
+ {children}
+
+ )
+}
diff --git a/app/(marketing)/layout.tsx b/app/(marketing)/layout.tsx
new file mode 100644
index 0000000..94344e6
--- /dev/null
+++ b/app/(marketing)/layout.tsx
@@ -0,0 +1,23 @@
+import { Icons } from "@/components/icons"
+import Link from "next/link"
+
+interface MarketingLayoutProps {
+ children: React.ReactNode
+}
+
+export default function MarketingLayout({ children }: MarketingLayoutProps) {
+ return (
+
+
+
+
+ Taxonomy
+
+
+ Login
+
+
+
{children}
+
+ )
+}
diff --git a/app/(marketing)/page.tsx b/app/(marketing)/page.tsx
new file mode 100644
index 0000000..a23c564
--- /dev/null
+++ b/app/(marketing)/page.tsx
@@ -0,0 +1,33 @@
+import { Icons } from "@/components/icons"
+import Image from "next/image"
+import Link from "next/link"
+
+export default function IndexPage() {
+ return (
+
+
+
+ Publishing Platform for Everyone
+
+
+ A Next.js 13 application built using layouts, server components and
+ everything new in React 18.
+
+
+ Get Started
+
+
+
+
+
+ )
+}
diff --git a/app/head.tsx b/app/head.tsx
new file mode 100644
index 0000000..735d373
--- /dev/null
+++ b/app/head.tsx
@@ -0,0 +1,9 @@
+export default function Head() {
+ return (
+ <>
+ Taxonomy
+
+
+ >
+ )
+}
diff --git a/app/layout.tsx b/app/layout.tsx
new file mode 100644
index 0000000..0df0270
--- /dev/null
+++ b/app/layout.tsx
@@ -0,0 +1,18 @@
+import "styles/globals.css"
+
+import { Toaster } from "@/components/ui/toast"
+
+interface RootLayoutProps {
+ children: React.ReactNode
+}
+
+export default function RootLayout({ children }: RootLayoutProps) {
+ return (
+
+
+ {children}
+
+
+
+ )
+}
diff --git a/components/dashboard-branding.tsx b/components/dashboard-branding.tsx
new file mode 100644
index 0000000..741d2e4
--- /dev/null
+++ b/components/dashboard-branding.tsx
@@ -0,0 +1,10 @@
+import { Icons } from "./icons"
+
+export function DashboardBranding() {
+ return (
+
+ )
+}
diff --git a/components/dashboard-header.tsx b/components/dashboard-header.tsx
new file mode 100644
index 0000000..c96add3
--- /dev/null
+++ b/components/dashboard-header.tsx
@@ -0,0 +1,23 @@
+interface DashboardHeaderProps {
+ heading: string
+ text?: string
+ children?: React.ReactNode
+}
+
+export function DashboardHeader({
+ heading,
+ text,
+ children,
+}: DashboardHeaderProps) {
+ return (
+
+
+
+ {heading}
+
+ {text &&
{text}
}
+
+ {children}
+
+ )
+}
diff --git a/components/dashboard-nav.tsx b/components/dashboard-nav.tsx
new file mode 100644
index 0000000..016a229
--- /dev/null
+++ b/components/dashboard-nav.tsx
@@ -0,0 +1,65 @@
+"use client"
+
+import Link from "next/link"
+import clsx from "clsx"
+import { usePathname } from "next/navigation"
+
+import { Icon, Icons } from "@/components/icons"
+
+export type NavigationItem = {
+ title: string
+ href: string
+ disabled?: boolean
+ icon?: Icon
+}
+
+export const navigationItems: NavigationItem[] = [
+ {
+ title: "Posts",
+ href: "/dashboard",
+ icon: Icons.post,
+ },
+ {
+ title: "Pages",
+ href: "/",
+ icon: Icons.page,
+ disabled: true,
+ },
+ {
+ title: "Media",
+ href: "/",
+ icon: Icons.media,
+ disabled: true,
+ },
+ {
+ title: "Settings",
+ href: "/dashboard/settings",
+ icon: Icons.settings,
+ },
+]
+
+export function DashboardNav() {
+ const path = usePathname()
+
+ return (
+
+ {navigationItems.map((navigationItem, index) => (
+
+
+
+ {navigationItem.title}
+
+
+ ))}
+
+ )
+}
diff --git a/components/dashboard-shell.tsx b/components/dashboard-shell.tsx
new file mode 100644
index 0000000..e5e1b02
--- /dev/null
+++ b/components/dashboard-shell.tsx
@@ -0,0 +1,16 @@
+import * as React from "react"
+import { cn } from "@/lib/utils"
+
+interface DashboardShellProps extends React.HTMLAttributes {}
+
+export function DashboardShell({
+ children,
+ className,
+ ...props
+}: DashboardShellProps) {
+ return (
+
+ {children}
+
+ )
+}
diff --git a/components/editor.tsx b/components/editor.tsx
new file mode 100644
index 0000000..e5b430f
--- /dev/null
+++ b/components/editor.tsx
@@ -0,0 +1,164 @@
+"use client"
+
+import * as React from "react"
+import EditorJS from "@editorjs/editorjs"
+import { Post } from "@prisma/client"
+import { useForm } from "react-hook-form"
+import Link from "next/link"
+import TextareaAutosize from "react-textarea-autosize"
+import * as z from "zod"
+import { zodResolver } from "@hookform/resolvers/zod"
+
+import { Icons } from "@/components/icons"
+import { postPatchSchema } from "@/lib/validations/post"
+import toast from "@/components/ui/toast"
+
+interface EditorProps {
+ post: Pick
+}
+
+type FormData = z.infer
+
+export function Editor({ post }: EditorProps) {
+ const { register, handleSubmit } = useForm({
+ resolver: zodResolver(postPatchSchema),
+ })
+ const ref = React.useRef()
+ const [isSaving, setIsSaving] = React.useState(false)
+ const [isMounted, setIsMounted] = React.useState(false)
+
+ async function initializeEditor() {
+ const EditorJS = (await import("@editorjs/editorjs")).default
+ const Header = (await import("@editorjs/header")).default
+ const Embed = (await import("@editorjs/embed")).default
+ const Table = (await import("@editorjs/table")).default
+ const List = (await import("@editorjs/list")).default
+ const Code = (await import("@editorjs/code")).default
+ const LinkTool = (await import("@editorjs/link")).default
+ const InlineCode = (await import("@editorjs/inline-code")).default
+
+ const body = postPatchSchema.parse(post)
+
+ if (!ref.current) {
+ const editor = new EditorJS({
+ holder: "editor",
+ onReady() {
+ ref.current = editor
+ },
+ placeholder: "Type here to write your post...",
+ inlineToolbar: true,
+ data: body.content,
+ tools: {
+ header: Header,
+ linkTool: LinkTool,
+ list: List,
+ code: Code,
+ inlineCode: InlineCode,
+ table: Table,
+ embed: Embed,
+ },
+ })
+ }
+ }
+
+ React.useEffect(() => {
+ if (typeof window !== "undefined") {
+ setIsMounted(true)
+ }
+ }, [])
+
+ React.useEffect(() => {
+ if (isMounted) {
+ initializeEditor()
+
+ return () => {
+ ref.current?.destroy()
+ ref.current = null
+ }
+ }
+ }, [isMounted])
+
+ async function onSubmit(data: FormData) {
+ setIsSaving(true)
+
+ const blocks = await ref.current.save()
+
+ const response = await fetch(`/api/posts/${post.id}`, {
+ method: "PATCH",
+ body: JSON.stringify({
+ title: data.title,
+ content: blocks,
+ }),
+ })
+
+ setIsSaving(false)
+
+ if (!response?.ok) {
+ return toast({
+ title: "Something went wrong.",
+ message: "Your post was not saved. Please try again.",
+ type: "error",
+ })
+ }
+
+ return toast({
+ message: "Your post has been saved.",
+ type: "success",
+ })
+ }
+
+ if (!isMounted) {
+ return null
+ }
+
+ return (
+
+ )
+}
diff --git a/components/empty-placeholder.tsx b/components/empty-placeholder.tsx
new file mode 100644
index 0000000..bd99a43
--- /dev/null
+++ b/components/empty-placeholder.tsx
@@ -0,0 +1,79 @@
+import * as React from "react"
+
+import { cn } from "@/lib/utils"
+import { Icons } from "@/components/icons"
+
+interface EmptyPlaceholderProps extends React.HTMLAttributes {}
+
+export function EmptyPlaceholder({
+ className,
+ children,
+ ...props
+}: EmptyPlaceholderProps) {
+ return (
+
+ )
+}
+
+interface EmptyPlaceholderIconProps
+ extends Partial> {
+ name: keyof typeof Icons
+}
+
+EmptyPlaceholder.Icon = function EmptyPlaceHolderIcon({
+ name,
+ className,
+ ...props
+}: EmptyPlaceholderIconProps) {
+ const Icon = Icons[name]
+
+ if (!Icon) {
+ return null
+ }
+
+ return (
+
+
+
+ )
+}
+
+interface EmptyPlacholderTitleProps
+ extends React.HTMLAttributes {}
+
+EmptyPlaceholder.Title = function EmptyPlaceholderTitle({
+ className,
+ ...props
+}: EmptyPlacholderTitleProps) {
+ return (
+
+ )
+}
+
+interface EmptyPlacholderDescriptionProps
+ extends React.HTMLAttributes {}
+
+EmptyPlaceholder.Description = function EmptyPlaceholderDescription({
+ className,
+ ...props
+}: EmptyPlacholderDescriptionProps) {
+ return (
+
+ )
+}
diff --git a/components/icons.tsx b/components/icons.tsx
new file mode 100644
index 0000000..a23e321
--- /dev/null
+++ b/components/icons.tsx
@@ -0,0 +1,34 @@
+import {
+ AlertTriangle,
+ ArrowRight,
+ ChevronLeft,
+ Command,
+ File,
+ FileText,
+ Image,
+ Loader2,
+ MoreVertical,
+ Plus,
+ Settings,
+ Trash,
+ User,
+} from "lucide-react"
+import type { Icon as LucideIcon } from "lucide-react"
+
+export type Icon = LucideIcon
+
+export const Icons = {
+ logo: Command,
+ spinner: Loader2,
+ chevronLeft: ChevronLeft,
+ trash: Trash,
+ post: FileText,
+ page: File,
+ media: Image,
+ settings: Settings,
+ ellipsis: MoreVertical,
+ add: Plus,
+ warning: AlertTriangle,
+ user: User,
+ arrowRight: ArrowRight,
+}
diff --git a/components/post-create-button.tsx b/components/post-create-button.tsx
new file mode 100644
index 0000000..e9504ac
--- /dev/null
+++ b/components/post-create-button.tsx
@@ -0,0 +1,69 @@
+"use client"
+
+import * as React from "react"
+import { useRouter } from "next/navigation"
+import { Post } from "@prisma/client"
+
+import { cn } from "@/lib/utils"
+import { Icons } from "@/components/icons"
+import toast from "@/components/ui/toast"
+
+async function createPost(): Promise> {
+ const response = await fetch("/api/posts", {
+ method: "POST",
+ body: JSON.stringify({
+ title: "Untitled Post",
+ }),
+ })
+
+ if (!response?.ok) {
+ toast({
+ title: "Something went wrong.",
+ message: "Your post was not created. Please try again.",
+ type: "error",
+ })
+ }
+
+ return await response.json()
+}
+
+interface PostCreateButtonProps
+ extends React.HTMLAttributes {}
+
+export function PostCreateButton({
+ className,
+ ...props
+}: PostCreateButtonProps) {
+ const router = useRouter()
+ const [isLoading, setIsLoading] = React.useState(false)
+
+ async function onClick() {
+ setIsLoading(!isLoading)
+
+ const post = await createPost()
+
+ router.push(`/editor/${post.id}`)
+ }
+
+ return (
+
+ {isLoading ? (
+
+ ) : (
+
+ )}
+ New post
+
+ )
+}
diff --git a/components/post-item.tsx b/components/post-item.tsx
new file mode 100644
index 0000000..6c3da62
--- /dev/null
+++ b/components/post-item.tsx
@@ -0,0 +1,42 @@
+import { formatDate } from "@/lib/utils"
+import { Post } from "@prisma/client"
+import Link from "next/link"
+
+import { PostOperations } from "@/components/post-operations"
+
+interface PostItemProps {
+ post: Pick
+}
+
+export function PostItem({ post }: PostItemProps) {
+ return (
+
+
+
+ {post.title}
+
+
+
+ {formatDate(post.createdAt?.toDateString())}
+
+
+
+
+ {/*
*/}
+
+ )
+}
+
+PostItem.Skeleton = function PostItemSkeleton() {
+ return (
+
+ )
+}
diff --git a/components/post-operations.tsx b/components/post-operations.tsx
new file mode 100644
index 0000000..2759a57
--- /dev/null
+++ b/components/post-operations.tsx
@@ -0,0 +1,98 @@
+"use client"
+
+import * as React from "react"
+import Link from "next/link"
+import { useRouter } from "next/navigation"
+import { Post } from "@prisma/client"
+
+import { DropdownMenu } from "@/components/ui/dropdown"
+import { Icons } from "@/components/icons"
+import { Alert } from "@/components/ui/alert"
+import toast from "@/components/ui/toast"
+
+async function deletePost(postId: string) {
+ const response = await fetch(`/api/posts/${postId}`, {
+ method: "DELETE",
+ })
+
+ if (!response?.ok) {
+ toast({
+ title: "Something went wrong.",
+ message: "Your post was not deleted. Please try again.",
+ type: "error",
+ })
+ }
+
+ return true
+}
+
+interface PostOperationsProps {
+ post: Pick
+}
+
+export function PostOperations({ post }: PostOperationsProps) {
+ const router = useRouter()
+ const [showDeleteAlert, setShowDeleteAlert] = React.useState(false)
+ const [isDeleteLoading, setIsDeleteLoading] = React.useState(false)
+
+ return (
+ <>
+
+
+
+
+
+
+
+
+ Edit
+
+
+
+ setShowDeleteAlert(true)}
+ >
+ Delete
+
+
+
+
+
+
+
+
+ Are you sure you want to delete this post?
+
+ This action cannot be undone.
+
+
+ Cancel
+ {
+ event.preventDefault()
+ setIsDeleteLoading(true)
+
+ const deleted = await deletePost(post.id)
+
+ if (deleted) {
+ setIsDeleteLoading(false)
+ setShowDeleteAlert(false)
+ router.refresh()
+ }
+ }}
+ className="bg-red-600 focus:ring-red-600"
+ >
+ {isDeleteLoading ? (
+
+ ) : (
+
+ )}
+ Delete
+
+
+
+
+ >
+ )
+}
diff --git a/components/ui/alert.tsx b/components/ui/alert.tsx
new file mode 100644
index 0000000..bdbbeda
--- /dev/null
+++ b/components/ui/alert.tsx
@@ -0,0 +1,112 @@
+"use client"
+
+import * as React from "react"
+import * as AlertDialogPrimitives from "@radix-ui/react-alert-dialog"
+import { cn } from "@/lib/utils"
+
+type AlertProps = AlertDialogPrimitives.AlertDialogProps
+
+export function Alert({ ...props }: AlertProps) {
+ return
+}
+
+Alert.Trigger = React.forwardRef<
+ HTMLButtonElement,
+ AlertDialogPrimitives.AlertDialogTriggerProps
+>(function AlertTrigger({ ...props }, ref) {
+ return
+})
+
+Alert.Portal = AlertDialogPrimitives.Portal
+
+Alert.Content = React.forwardRef<
+ HTMLDivElement,
+ AlertDialogPrimitives.AlertDialogContentProps
+>(function AlertContent({ className, ...props }, ref) {
+ return (
+
+
+
+
+
+ )
+})
+
+type AlertHeaderProps = React.HTMLAttributes
+
+Alert.Header = function AlertHeader({ className, ...props }: AlertHeaderProps) {
+ return
+}
+
+Alert.Title = React.forwardRef<
+ HTMLHeadingElement,
+ AlertDialogPrimitives.AlertDialogTitleProps
+>(function AlertTitle({ className, ...props }, ref) {
+ return (
+
+ )
+})
+
+Alert.Description = React.forwardRef<
+ HTMLParagraphElement,
+ AlertDialogPrimitives.AlertDialogDescriptionProps
+>(function AlertDescription({ className, ...props }, ref) {
+ return (
+
+ )
+})
+
+Alert.Footer = function AlertFooter({ className, ...props }: AlertHeaderProps) {
+ return (
+
+ )
+}
+
+Alert.Cancel = React.forwardRef<
+ HTMLButtonElement,
+ AlertDialogPrimitives.AlertDialogCancelProps
+>(function AlertCancel({ className, ...props }, ref) {
+ return (
+
+ )
+})
+
+Alert.Action = React.forwardRef<
+ HTMLButtonElement,
+ AlertDialogPrimitives.AlertDialogActionProps
+>(function AlertAction({ className, ...props }, ref) {
+ return (
+
+ )
+})
diff --git a/components/ui/avatar.tsx b/components/ui/avatar.tsx
new file mode 100644
index 0000000..906b28d
--- /dev/null
+++ b/components/ui/avatar.tsx
@@ -0,0 +1,40 @@
+import * as AvatarPrimitive from "@radix-ui/react-avatar"
+
+import { cn } from "@/lib/utils"
+import Image, { ImageProps } from "next/image"
+
+type AvatarProps = AvatarPrimitive.AvatarProps
+
+export function Avatar({ className, ...props }: AvatarProps) {
+ return (
+
+ )
+}
+
+type AvatarImageProps = AvatarPrimitive.AvatarImageProps
+
+Avatar.Image = function AvatarImage({ className, ...props }: AvatarImageProps) {
+ return
+}
+
+Avatar.Fallback = function AvatarFallback({
+ className,
+ children,
+ ...props
+}: AvatarPrimitive.AvatarFallbackProps) {
+ return (
+
+ {children}
+
+ )
+}
diff --git a/components/ui/card.tsx b/components/ui/card.tsx
new file mode 100644
index 0000000..f543591
--- /dev/null
+++ b/components/ui/card.tsx
@@ -0,0 +1,66 @@
+import { cn } from "@/lib/utils"
+
+interface CardProps extends React.HTMLAttributes {}
+
+export function Card({ className, ...props }: CardProps) {
+ return (
+
+ )
+}
+
+interface CardHeaderProps extends React.HTMLAttributes {}
+
+Card.Header = function CardHeader({ className, ...props }: CardHeaderProps) {
+ return
+}
+
+interface CardContentProps extends React.HTMLAttributes {}
+
+Card.Content = function CardContent({ className, ...props }: CardContentProps) {
+ return
+}
+
+interface CardFooterProps extends React.HTMLAttributes {}
+
+Card.Footer = function CardFooter({ className, ...props }: CardFooterProps) {
+ return (
+
+ )
+}
+
+interface CardTitleProps extends React.HTMLAttributes {}
+
+Card.Title = function CardTitle({ className, ...props }: CardTitleProps) {
+ return
+}
+
+interface CardDescriptionProps
+ extends React.HTMLAttributes {}
+
+Card.Description = function CardDescription({
+ className,
+ ...props
+}: CardDescriptionProps) {
+ return
+}
+
+Card.Skeleton = function CardSeleton() {
+ return (
+
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/components/ui/dropdown.tsx b/components/ui/dropdown.tsx
new file mode 100644
index 0000000..0b9cca7
--- /dev/null
+++ b/components/ui/dropdown.tsx
@@ -0,0 +1,67 @@
+"use client"
+
+import * as React from "react"
+import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
+
+import { cn } from "@/lib/utils"
+
+type DropdownMenuProps = DropdownMenuPrimitive.DropdownMenuProps
+
+export function DropdownMenu({ ...props }: DropdownMenuProps) {
+ return
+}
+
+DropdownMenu.Trigger = React.forwardRef<
+ HTMLButtonElement,
+ DropdownMenuPrimitive.DropdownMenuTriggerProps
+>(function DropdownMenuTrigger({ ...props }, ref) {
+ return
+})
+
+DropdownMenu.Portal = DropdownMenuPrimitive.Portal
+
+DropdownMenu.Content = React.forwardRef<
+ HTMLDivElement,
+ DropdownMenuPrimitive.MenuContentProps
+>(function DropdownMenuContent({ className, ...props }, ref) {
+ return (
+
+ )
+})
+
+DropdownMenu.Item = React.forwardRef<
+ HTMLDivElement,
+ DropdownMenuPrimitive.DropdownMenuItemProps
+>(function DropdownMenuItem({ className, ...props }, ref) {
+ return (
+
+ )
+})
+
+DropdownMenu.Separator = React.forwardRef<
+ HTMLDivElement,
+ DropdownMenuPrimitive.DropdownMenuSeparatorProps
+>(function DropdownMenuItem({ className, ...props }, ref) {
+ return (
+
+ )
+})
diff --git a/components/ui/toast.tsx b/components/ui/toast.tsx
new file mode 100644
index 0000000..c63b90c
--- /dev/null
+++ b/components/ui/toast.tsx
@@ -0,0 +1,89 @@
+"use client"
+
+import * as React from "react"
+import hotToast, { Toaster as HotToaster } from "react-hot-toast"
+
+import { cn } from "@/lib/utils"
+import { Icons } from "@/components/icons"
+
+export const Toaster = HotToaster
+
+interface ToastProps extends React.HTMLAttributes {
+ visible: boolean
+}
+
+export function Toast({ visible, className, ...props }: ToastProps) {
+ return (
+
+ )
+}
+
+interface ToastIconProps extends Partial> {
+ name: keyof typeof Icons
+}
+
+Toast.Icon = function ToastIcon({ name, className, ...props }: ToastIconProps) {
+ const Icon = Icons[name]
+
+ if (!Icon) {
+ return null
+ }
+
+ return (
+
+
+
+ )
+}
+
+interface ToastTitleProps extends React.HTMLAttributes {}
+
+Toast.Title = function ToastTitle({ className, ...props }: ToastTitleProps) {
+ return
+}
+
+interface ToastDescriptionProps
+ extends React.HTMLAttributes {}
+
+Toast.Description = function ToastDescription({
+ className,
+ ...props
+}: ToastDescriptionProps) {
+ return
+}
+
+interface ToastOpts {
+ title?: string
+ message: string
+ type?: "success" | "error" | "default"
+ duration?: number
+}
+
+export default function toast(opts: ToastOpts) {
+ const { title, message, type = "default", duration = 3000 } = opts
+
+ // alert(message)
+
+ return hotToast.custom(
+ ({ visible }) => (
+
+ {title}
+ {message && {message} }
+
+ ),
+ { duration }
+ )
+}
diff --git a/components/user-account-nav.tsx b/components/user-account-nav.tsx
new file mode 100644
index 0000000..4d1d874
--- /dev/null
+++ b/components/user-account-nav.tsx
@@ -0,0 +1,77 @@
+"use client"
+
+import { User } from "next-auth"
+import { signOut } from "next-auth/react"
+
+import { DropdownMenu } from "@/components/ui/dropdown"
+import { Icons } from "@/components/icons"
+import { UserAvatar } from "./user-avatar"
+import Link from "next/link"
+
+interface UserAccountNavProps extends React.HTMLAttributes {
+ user: Pick
+}
+
+export function UserAccountNav({ user }: UserAccountNavProps) {
+ return (
+
+
+
+
+ {user.name &&
{user.name}
}
+
+ Pro
+
+
+
+
+
+
+
+
+ {user.name &&
{user.name}
}
+ {user.email && (
+
+ {user.email}
+
+ )}
+
+
+
+
+
+ Dashboard
+
+
+
+
+ Settings
+
+
+
+
+
+ GitHub
+
+
+
+ {
+ event.preventDefault()
+ signOut({
+ callbackUrl: `${window.location.origin}/login`,
+ })
+ }}
+ >
+ Sign out
+
+
+
+
+ )
+}
diff --git a/components/user-auth-form.tsx b/components/user-auth-form.tsx
new file mode 100644
index 0000000..92dbf1f
--- /dev/null
+++ b/components/user-auth-form.tsx
@@ -0,0 +1,120 @@
+"use client"
+
+import * as React from "react"
+import { signIn } from "next-auth/react"
+import * as z from "zod"
+import { useForm } from "react-hook-form"
+import { zodResolver } from "@hookform/resolvers/zod"
+
+import { cn } from "@/lib/utils"
+import { userAuthSchema } from "@/lib/validations/auth"
+import toast from "@/components/ui/toast"
+import { Icons } from "@/components/icons"
+
+interface UserAuthFormProps extends React.HTMLAttributes {}
+
+type FormData = z.infer
+
+export function UserAuthForm({ className, ...props }: UserAuthFormProps) {
+ const {
+ register,
+ handleSubmit,
+ formState: { errors },
+ } = useForm({
+ resolver: zodResolver(userAuthSchema),
+ })
+ const [isLoading, setIsLoading] = React.useState(false)
+
+ async function onSubmit(data: FormData) {
+ setIsLoading(true)
+
+ const signInResult = await signIn("email", {
+ email: data.email.toLowerCase(),
+ redirect: false,
+ callbackUrl: `${window.location.origin}/dashboard`,
+ })
+
+ setIsLoading(false)
+
+ if (!signInResult?.ok) {
+ return toast({
+ title: "Something went wrong.",
+ message: "Your post was not saved. Please try again.",
+ type: "error",
+ })
+ }
+
+ return toast({
+ title: "Check your email",
+ message: "We sent you a login link. Be sure to check your spam too.",
+ type: "success",
+ })
+ }
+
+ return (
+
+
+
+
+
+ Or continue with
+
+
+
signIn("github")}
+ >
+
+
+
+ Github
+
+
+ )
+}
diff --git a/components/user-avatar.tsx b/components/user-avatar.tsx
new file mode 100644
index 0000000..28ecd86
--- /dev/null
+++ b/components/user-avatar.tsx
@@ -0,0 +1,21 @@
+import { User } from "@prisma/client"
+import { AvatarProps } from "@radix-ui/react-avatar"
+
+import { Icons } from "@/components/icons"
+import { Avatar } from "@/components/ui/avatar"
+
+interface UserAvatarProps extends AvatarProps {
+ user: Pick
+}
+
+export function UserAvatar({ user, ...props }: UserAvatarProps) {
+ return (
+
+
+
+ {user.name}
+
+
+
+ )
+}
diff --git a/components/user-name-form.tsx b/components/user-name-form.tsx
new file mode 100644
index 0000000..41ca795
--- /dev/null
+++ b/components/user-name-form.tsx
@@ -0,0 +1,116 @@
+"use client"
+
+import * as React from "react"
+import { User } from "@prisma/client"
+import { useForm } from "react-hook-form"
+import * as z from "zod"
+import { zodResolver } from "@hookform/resolvers/zod"
+import { useRouter } from "next/navigation"
+
+import { cn } from "@/lib/utils"
+import { userNameSchema } from "@/lib/validations/user"
+import { Card } from "@/components/ui/card"
+import { Icons } from "@/components/icons"
+import toast from "@/components/ui/toast"
+
+interface UserNameFormProps extends React.HTMLAttributes {
+ user: Pick
+}
+
+type FormData = z.infer
+
+export function UserNameForm({ user, className, ...props }: UserNameFormProps) {
+ const router = useRouter()
+ const {
+ handleSubmit,
+ register,
+ formState: { errors },
+ } = useForm({
+ resolver: zodResolver(userNameSchema),
+ defaultValues: {
+ name: user.name,
+ },
+ })
+ const [isSaving, setIsSaving] = React.useState(false)
+
+ async function onSubmit(data: FormData) {
+ setIsSaving(true)
+
+ const response = await fetch(`/api/users/${user.id}`, {
+ method: "PATCH",
+ body: JSON.stringify({
+ name: data.name,
+ }),
+ })
+
+ setIsSaving(false)
+
+ if (!response?.ok) {
+ return toast({
+ title: "Something went wrong.",
+ message: "Your name was not updated. Please try again.",
+ type: "error",
+ })
+ }
+
+ toast({
+ message: "Your name has been updated.",
+ type: "success",
+ })
+
+ router.refresh()
+ }
+
+ return (
+
+ )
+}
diff --git a/lib/api-middlewares/with-authentication.ts b/lib/api-middlewares/with-authentication.ts
new file mode 100644
index 0000000..74f73cb
--- /dev/null
+++ b/lib/api-middlewares/with-authentication.ts
@@ -0,0 +1,14 @@
+import type { NextApiHandler, NextApiRequest, NextApiResponse } from "next"
+import { getSession } from "next-auth/react"
+
+export function withAuthentication(handler: NextApiHandler) {
+ return async function (req: NextApiRequest, res: NextApiResponse) {
+ const session = await getSession({ req })
+
+ if (!session) {
+ return res.status(403).end()
+ }
+
+ return handler(req, res)
+ }
+}
diff --git a/lib/api-middlewares/with-methods.ts b/lib/api-middlewares/with-methods.ts
new file mode 100644
index 0000000..cf358b4
--- /dev/null
+++ b/lib/api-middlewares/with-methods.ts
@@ -0,0 +1,11 @@
+import type { NextApiHandler, NextApiRequest, NextApiResponse } from "next"
+
+export function withMethods(methods: string[], handler: NextApiHandler) {
+ return async function (req: NextApiRequest, res: NextApiResponse) {
+ if (!methods.includes(req.method)) {
+ return res.status(405).end()
+ }
+
+ return handler(req, res)
+ }
+}
diff --git a/lib/api-middlewares/with-post.ts b/lib/api-middlewares/with-post.ts
new file mode 100644
index 0000000..96721ea
--- /dev/null
+++ b/lib/api-middlewares/with-post.ts
@@ -0,0 +1,37 @@
+import type { NextApiHandler, NextApiRequest, NextApiResponse } from "next"
+import { getSession } from "next-auth/react"
+import * as z from "zod"
+import { db } from "../db"
+
+export const schema = z.object({
+ postId: z.string(),
+})
+
+export function withPost(handler: NextApiHandler) {
+ return async function (req: NextApiRequest, res: NextApiResponse) {
+ try {
+ const query = await schema.parse(req.query)
+
+ // Check if the user has access to this post.
+ const session = await getSession({ req })
+ const count = await db.post.count({
+ where: {
+ id: query.postId,
+ authorId: session.user.id,
+ },
+ })
+
+ if (count < 1) {
+ return res.status(403).end()
+ }
+
+ return handler(req, res)
+ } catch (error) {
+ if (error instanceof z.ZodError) {
+ return res.status(422).json(error.issues)
+ }
+
+ return res.status(500).end()
+ }
+ }
+}
diff --git a/lib/api-middlewares/with-user.ts b/lib/api-middlewares/with-user.ts
new file mode 100644
index 0000000..8117afa
--- /dev/null
+++ b/lib/api-middlewares/with-user.ts
@@ -0,0 +1,30 @@
+import type { NextApiHandler, NextApiRequest, NextApiResponse } from "next"
+import { getSession } from "next-auth/react"
+import * as z from "zod"
+
+export const schema = z.object({
+ userId: z.string(),
+})
+
+export function withUser(handler: NextApiHandler) {
+ return async function (req: NextApiRequest, res: NextApiResponse) {
+ try {
+ const query = await schema.parse(req.query)
+
+ // Check if the user has access to this user.
+ const session = await getSession({ req })
+
+ if (query.userId !== session?.user.id) {
+ return res.status(403).end()
+ }
+
+ return handler(req, res)
+ } catch (error) {
+ if (error instanceof z.ZodError) {
+ return res.status(422).json(error.issues)
+ }
+
+ return res.status(500).end()
+ }
+ }
+}
diff --git a/lib/api-middlewares/with-validation.ts b/lib/api-middlewares/with-validation.ts
new file mode 100644
index 0000000..43b26bb
--- /dev/null
+++ b/lib/api-middlewares/with-validation.ts
@@ -0,0 +1,24 @@
+import type { NextApiHandler, NextApiRequest, NextApiResponse } from "next"
+import * as z from "zod"
+import type { ZodSchema } from "zod"
+
+export function withValidation(
+ schema: T,
+ handler: NextApiHandler
+) {
+ return async function (req: NextApiRequest, res: NextApiResponse) {
+ try {
+ const body = req.body ? JSON.parse(req.body) : {}
+
+ await schema.parse(body)
+
+ return handler(req, res)
+ } catch (error) {
+ if (error instanceof z.ZodError) {
+ return res.status(422).json(error.issues)
+ }
+
+ return res.status(422).end()
+ }
+ }
+}
diff --git a/lib/db.ts b/lib/db.ts
new file mode 100644
index 0000000..b772423
--- /dev/null
+++ b/lib/db.ts
@@ -0,0 +1,18 @@
+import { PrismaClient } from "@prisma/client"
+
+declare global {
+ // eslint-disable-next-line no-var
+ var cachedPrisma: PrismaClient
+}
+
+let prisma: PrismaClient
+if (process.env.NODE_ENV === "production") {
+ prisma = new PrismaClient()
+} else {
+ if (!global.cachedPrisma) {
+ global.cachedPrisma = new PrismaClient()
+ }
+ prisma = global.cachedPrisma
+}
+
+export const db = prisma
diff --git a/lib/models.ts b/lib/models.ts
new file mode 100644
index 0000000..3dc4cf1
--- /dev/null
+++ b/lib/models.ts
@@ -0,0 +1,7 @@
+import { Post } from ".prisma/client"
+
+export type SelectFields = {
+ [K in keyof T]?: true
+}
+
+export type PostItem = Pick
diff --git a/lib/session.ts b/lib/session.ts
new file mode 100644
index 0000000..8f38bbe
--- /dev/null
+++ b/lib/session.ts
@@ -0,0 +1,15 @@
+import { Session } from "next-auth"
+
+export async function getSession(cookie: string): Promise {
+ const response = await fetch("http://localhost:3000/api/auth/session", {
+ headers: { cookie },
+ })
+
+ if (!response?.ok) {
+ return null
+ }
+
+ const session = await response.json()
+
+ return Object.keys(session).length > 0 ? session : null
+}
diff --git a/lib/utils.ts b/lib/utils.ts
new file mode 100644
index 0000000..513abb9
--- /dev/null
+++ b/lib/utils.ts
@@ -0,0 +1,15 @@
+import { ClassValue, clsx } from "clsx"
+import { twMerge } from "tailwind-merge"
+
+export function cn(...inputs: ClassValue[]) {
+ return twMerge(clsx(inputs))
+}
+
+export function formatDate(input: string): string {
+ const date = new Date(input)
+ return date.toLocaleDateString("en-US", {
+ month: "long",
+ day: "numeric",
+ year: "numeric",
+ })
+}
diff --git a/lib/validations/auth.ts b/lib/validations/auth.ts
new file mode 100644
index 0000000..b91e85a
--- /dev/null
+++ b/lib/validations/auth.ts
@@ -0,0 +1,5 @@
+import * as z from "zod"
+
+export const userAuthSchema = z.object({
+ email: z.string().email(),
+})
diff --git a/lib/validations/post.ts b/lib/validations/post.ts
new file mode 100644
index 0000000..73ad1c6
--- /dev/null
+++ b/lib/validations/post.ts
@@ -0,0 +1,8 @@
+import * as z from "zod"
+
+export const postPatchSchema = z.object({
+ title: z.string().min(3).max(128).optional(),
+
+ // TODO: Type this properly from editorjs block types?
+ content: z.any().optional(),
+})
diff --git a/lib/validations/user.ts b/lib/validations/user.ts
new file mode 100644
index 0000000..035cc5a
--- /dev/null
+++ b/lib/validations/user.ts
@@ -0,0 +1,5 @@
+import * as z from "zod"
+
+export const userNameSchema = z.object({
+ name: z.string().min(3).max(32),
+})
diff --git a/middleware.ts b/middleware.ts
new file mode 100644
index 0000000..276ee31
--- /dev/null
+++ b/middleware.ts
@@ -0,0 +1,22 @@
+import { NextRequest, NextResponse } from "next/server"
+import { getSession } from "./lib/session"
+
+export async function middleware(req: NextRequest) {
+ const session = await getSession(req.headers.get("cookie"))
+ // console.log(sess)
+ // next-auth middleware is not Next.js 13 ready.
+ // For now we just check for a cookie.
+ // Totally not safe but YOLO.
+ // const cookie = request.headers.get("cookie")
+ // console.log({ cookie })
+
+ if (!session && !req.nextUrl.pathname.startsWith("/login")) {
+ return NextResponse.redirect(new URL("/login", req.url))
+ }
+
+ if (session && req.nextUrl.pathname.startsWith("/login")) {
+ return NextResponse.redirect(new URL("/dashboard", req.url))
+ }
+}
+
+export const config = { matcher: ["/login", "/dashboard/:path*"] }
diff --git a/next.config.js b/next.config.js
new file mode 100644
index 0000000..e6a79b7
--- /dev/null
+++ b/next.config.js
@@ -0,0 +1,14 @@
+/** @type {import('next').NextConfig} */
+const nextConfig = {
+ reactStrictMode: true,
+ images: {
+ domains: ["avatars.githubusercontent.com"],
+ },
+ experimental: {
+ appDir: true,
+ newNextLinkBehavior: true,
+ serverComponentsExternalPackages: ["prisma"],
+ },
+}
+
+module.exports = nextConfig
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..85631ec
--- /dev/null
+++ b/package.json
@@ -0,0 +1,68 @@
+{
+ "name": "taxonomy",
+ "version": "0.1.0",
+ "private": true,
+ "author": {
+ "name": "shadcn",
+ "url": "https://twitter.com/shadcn"
+ },
+ "scripts": {
+ "dev": "next dev",
+ "build": "next build",
+ "start": "next start",
+ "lint": "next lint",
+ "preview": "next build && next start"
+ },
+ "packageManager": "yarn@1.22.19",
+ "dependencies": {
+ "@editorjs/code": "^2.7.0",
+ "@editorjs/editorjs": "^2.25.0",
+ "@editorjs/embed": "^2.5.3",
+ "@editorjs/header": "^2.6.2",
+ "@editorjs/inline-code": "^1.3.1",
+ "@editorjs/link": "^2.4.1",
+ "@editorjs/list": "^1.7.0",
+ "@editorjs/paragraph": "^2.8.0",
+ "@editorjs/table": "^2.0.4",
+ "@hookform/resolvers": "^2.9.10",
+ "@next-auth/prisma-adapter": "^1.0.4",
+ "@prisma/client": "^4.4.0",
+ "@radix-ui/react-alert-dialog": "^1.0.2",
+ "@radix-ui/react-avatar": "^1.0.1",
+ "@radix-ui/react-dropdown-menu": "^2.0.1",
+ "@radix-ui/react-popover": "1.0.0",
+ "@radix-ui/react-toggle": "^1.0.0",
+ "clsx": "^1.2.1",
+ "lucide-react": "^0.92.0",
+ "next": "^13.0.0",
+ "next-auth": "^4.10.3",
+ "nodemailer": "^6.8.0",
+ "postmark": "^3.0.14",
+ "prop-types": "^15.8.1",
+ "react": "^18.2.0",
+ "react-dom": "^18.2.0",
+ "react-editor-js": "^2.1.0",
+ "react-hook-form": "^7.38.0",
+ "react-hot-toast": "^2.4.0",
+ "react-textarea-autosize": "^8.3.4",
+ "sharp": "^0.31.1",
+ "tailwind-merge": "^1.6.2",
+ "tailwindcss-animate": "^1.0.5",
+ "zod": "^3.19.1"
+ },
+ "devDependencies": {
+ "@tailwindcss/typography": "^0.5.7",
+ "@types/node": "18.6.4",
+ "@types/react": "18.0.15",
+ "@types/react-dom": "18.0.6",
+ "autoprefixer": "^10.4.8",
+ "eslint": "8.21.0",
+ "eslint-config-next": "^13.0.0",
+ "postcss": "^8.4.14",
+ "prettier": "^2.7.1",
+ "prettier-plugin-tailwindcss": "^0.1.13",
+ "prisma": "^4.4.0",
+ "tailwindcss": "^3.1.7",
+ "typescript": "4.7.4"
+ }
+}
diff --git a/pages/.gitkeep b/pages/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/pages/api/auth/[...nextauth].ts b/pages/api/auth/[...nextauth].ts
new file mode 100644
index 0000000..505d85c
--- /dev/null
+++ b/pages/api/auth/[...nextauth].ts
@@ -0,0 +1,77 @@
+import NextAuth, { NextAuthOptions } from "next-auth"
+import GitHubProvider from "next-auth/providers/github"
+import EmailProvider from "next-auth/providers/email"
+import { PrismaAdapter } from "@next-auth/prisma-adapter"
+import { PrismaClient } from "@prisma/client"
+import { Client } from "postmark"
+import { db } from "@/lib/db"
+
+const postmarkClient = new Client(process.env.POSTMARK_API_TOKEN)
+
+const POSTMARK_SIGN_IN_TEMPLATE = 29559329
+const POSTMARK_ACTIVATION_TEMPLATE = 29559329
+
+const prisma = new PrismaClient()
+
+export const authOptions: NextAuthOptions = {
+ adapter: PrismaAdapter(prisma),
+ providers: [
+ GitHubProvider({
+ clientId: process.env.GITHUB_CLIENT_ID,
+ clientSecret: process.env.GITHUB_CLIENT_SECRET,
+ }),
+ EmailProvider({
+ server: {
+ host: process.env.SMTP_HOST,
+ port: Number(process.env.SMTP_PORT),
+ auth: {
+ user: process.env.SMTP_USER,
+ pass: process.env.SMTP_PASSWORD,
+ },
+ },
+ from: process.env.SMTP_FROM,
+ sendVerificationRequest: async ({ identifier, url, provider }) => {
+ const user = await db.user.findUnique({
+ where: {
+ email: identifier,
+ },
+ select: {
+ emailVerified: true,
+ },
+ })
+
+ const result = await postmarkClient.sendEmailWithTemplate({
+ TemplateId: user?.emailVerified
+ ? POSTMARK_SIGN_IN_TEMPLATE
+ : POSTMARK_ACTIVATION_TEMPLATE,
+ To: identifier,
+ From: provider.from,
+ TemplateModel: {
+ action_url: url,
+ product_name: "Taxonomy",
+ },
+ Headers: [
+ {
+ // Set this to prevent Gmail from threading emails.
+ // See https://stackoverflow.com/questions/23434110/force-emails-not-to-be-grouped-into-conversations/25435722.
+ Name: "X-Entity-Ref-ID",
+ Value: new Date().getTime() + "",
+ },
+ ],
+ })
+
+ if (result.ErrorCode) {
+ throw new Error(result.Message)
+ }
+ },
+ }),
+ ],
+ callbacks: {
+ async session({ session, token, user }) {
+ session.user.id = user.id
+ return session
+ },
+ },
+}
+
+export default NextAuth(authOptions)
diff --git a/pages/api/posts/[postId].ts b/pages/api/posts/[postId].ts
new file mode 100644
index 0000000..9758424
--- /dev/null
+++ b/pages/api/posts/[postId].ts
@@ -0,0 +1,62 @@
+import { NextApiRequest, NextApiResponse } from "next"
+import * as z from "zod"
+
+import { withAuthentication } from "@/lib/api-middlewares/with-authentication"
+import { withMethods } from "@/lib/api-middlewares/with-methods"
+import { withPost } from "@/lib/api-middlewares/with-post"
+import { db } from "@/lib/db"
+import { postPatchSchema } from "@/lib/validations/post"
+
+async function handler(req: NextApiRequest, res: NextApiResponse) {
+ if (req.method === "DELETE") {
+ try {
+ await db.post.delete({
+ where: {
+ id: req.query.postId as string,
+ },
+ })
+
+ return res.status(204).end()
+ } catch (error) {
+ return res.status(500).end()
+ }
+ }
+
+ if (req.method === "PATCH") {
+ try {
+ const postId = req.query.postId as string
+ const post = await db.post.findUnique({
+ where: {
+ id: postId,
+ },
+ })
+
+ const body = postPatchSchema.parse(JSON.parse(req.body))
+
+ // TODO: Implement sanitization for content.
+
+ await db.post.update({
+ where: {
+ id: post.id,
+ },
+ data: {
+ title: body.title || post.title,
+ content: body.content,
+ },
+ })
+
+ return res.end()
+ } catch (error) {
+ if (error instanceof z.ZodError) {
+ return res.status(422).json(error.issues)
+ }
+
+ return res.status(422).end()
+ }
+ }
+}
+
+export default withMethods(
+ ["DELETE", "PATCH"],
+ withAuthentication(withPost(handler))
+)
diff --git a/pages/api/posts/index.ts b/pages/api/posts/index.ts
new file mode 100644
index 0000000..abd55f8
--- /dev/null
+++ b/pages/api/posts/index.ts
@@ -0,0 +1,64 @@
+import { NextApiRequest, NextApiResponse } from "next"
+import { getSession } from "next-auth/react"
+import * as z from "zod"
+
+import { db } from "@/lib/db"
+import { withMethods } from "@/lib/api-middlewares/with-methods"
+import { withAuthentication } from "@/lib/api-middlewares/with-authentication"
+import { withPost } from "@/lib/api-middlewares/with-post"
+
+const postCreateSchema = z.object({
+ title: z.string().optional(),
+ content: z.string().optional(),
+})
+
+async function handler(req: NextApiRequest, res: NextApiResponse) {
+ const session = await getSession({ req })
+
+ if (req.method === "GET") {
+ try {
+ const posts = await db.post.findMany({
+ select: {
+ id: true,
+ title: true,
+ published: true,
+ createdAt: true,
+ },
+ where: {
+ authorId: session.user.id,
+ },
+ })
+
+ return res.json(posts)
+ } catch (error) {
+ return res.status(500).end()
+ }
+ }
+
+ if (req.method === "POST") {
+ try {
+ const body = postCreateSchema.parse(JSON.parse(req.body))
+
+ const post = await db.post.create({
+ data: {
+ title: body.title,
+ content: body.content,
+ authorId: session.user.id,
+ },
+ select: {
+ id: true,
+ },
+ })
+
+ return res.json(post)
+ } catch (error) {
+ if (error instanceof z.ZodError) {
+ return res.status(422).json(error.issues)
+ }
+
+ return res.status(500).end()
+ }
+ }
+}
+
+export default withMethods(["GET", "POST"], withAuthentication(handler))
diff --git a/pages/api/users/[userId].ts b/pages/api/users/[userId].ts
new file mode 100644
index 0000000..893afe6
--- /dev/null
+++ b/pages/api/users/[userId].ts
@@ -0,0 +1,43 @@
+import { NextApiRequest, NextApiResponse } from "next"
+import * as z from "zod"
+import { getSession } from "next-auth/react"
+
+import { withAuthentication } from "@/lib/api-middlewares/with-authentication"
+import { withMethods } from "@/lib/api-middlewares/with-methods"
+import { db } from "@/lib/db"
+import { withUser } from "@/lib/api-middlewares/with-user"
+import { userNameSchema } from "@/lib/validations/user"
+
+async function handler(req: NextApiRequest, res: NextApiResponse) {
+ if (req.method === "PATCH") {
+ try {
+ const session = await getSession({ req })
+ const user = session?.user
+
+ const body = JSON.parse(req.body)
+
+ if (body?.name) {
+ const payload = userNameSchema.parse(body)
+
+ await db.user.update({
+ where: {
+ id: user.id,
+ },
+ data: {
+ name: payload.name,
+ },
+ })
+ }
+
+ return res.end()
+ } catch (error) {
+ if (error instanceof z.ZodError) {
+ return res.status(422).json(error.issues)
+ }
+
+ return res.status(422).end()
+ }
+ }
+}
+
+export default withMethods(["PATCH"], withAuthentication(withUser(handler)))
diff --git a/postcss.config.js b/postcss.config.js
new file mode 100644
index 0000000..33ad091
--- /dev/null
+++ b/postcss.config.js
@@ -0,0 +1,6 @@
+module.exports = {
+ plugins: {
+ tailwindcss: {},
+ autoprefixer: {},
+ },
+}
diff --git a/prisma/migrations/20221021182747_init/migration.sql b/prisma/migrations/20221021182747_init/migration.sql
new file mode 100644
index 0000000..f7bde96
--- /dev/null
+++ b/prisma/migrations/20221021182747_init/migration.sql
@@ -0,0 +1,77 @@
+-- CreateTable
+CREATE TABLE `accounts` (
+ `id` VARCHAR(191) NOT NULL,
+ `userId` VARCHAR(191) NOT NULL,
+ `type` VARCHAR(191) NOT NULL,
+ `provider` VARCHAR(191) NOT NULL,
+ `providerAccountId` VARCHAR(191) NOT NULL,
+ `refresh_token` TEXT NULL,
+ `access_token` TEXT NULL,
+ `expires_at` INTEGER NULL,
+ `token_type` VARCHAR(191) NULL,
+ `scope` VARCHAR(191) NULL,
+ `id_token` TEXT NULL,
+ `session_state` VARCHAR(191) NULL,
+ `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
+ `updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
+
+ UNIQUE INDEX `accounts_provider_providerAccountId_key`(`provider`, `providerAccountId`),
+ PRIMARY KEY (`id`)
+) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
+
+-- CreateTable
+CREATE TABLE `sessions` (
+ `id` VARCHAR(191) NOT NULL,
+ `sessionToken` VARCHAR(191) NOT NULL,
+ `userId` VARCHAR(191) NOT NULL,
+ `expires` DATETIME(3) NOT NULL,
+
+ UNIQUE INDEX `sessions_sessionToken_key`(`sessionToken`),
+ PRIMARY KEY (`id`)
+) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
+
+-- CreateTable
+CREATE TABLE `users` (
+ `id` VARCHAR(191) NOT NULL,
+ `name` VARCHAR(191) NULL,
+ `email` VARCHAR(191) NULL,
+ `emailVerified` DATETIME(3) NULL,
+ `image` VARCHAR(191) NULL,
+ `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
+ `updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
+
+ UNIQUE INDEX `users_email_key`(`email`),
+ PRIMARY KEY (`id`)
+) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
+
+-- CreateTable
+CREATE TABLE `verification_tokens` (
+ `identifier` VARCHAR(191) NOT NULL,
+ `token` VARCHAR(191) NOT NULL,
+ `expires` DATETIME(3) NOT NULL,
+
+ UNIQUE INDEX `verification_tokens_token_key`(`token`),
+ UNIQUE INDEX `verification_tokens_identifier_token_key`(`identifier`, `token`)
+) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
+
+-- CreateTable
+CREATE TABLE `posts` (
+ `id` VARCHAR(191) NOT NULL,
+ `title` VARCHAR(191) NOT NULL,
+ `content` JSON NULL,
+ `published` BOOLEAN NOT NULL DEFAULT false,
+ `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
+ `updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
+ `authorId` VARCHAR(191) NOT NULL,
+
+ PRIMARY KEY (`id`)
+) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
+
+-- AddForeignKey
+ALTER TABLE `accounts` ADD CONSTRAINT `accounts_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `users`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE `sessions` ADD CONSTRAINT `sessions_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `users`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE `posts` ADD CONSTRAINT `posts_authorId_fkey` FOREIGN KEY (`authorId`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
diff --git a/prisma/migrations/migration_lock.toml b/prisma/migrations/migration_lock.toml
new file mode 100644
index 0000000..e5a788a
--- /dev/null
+++ b/prisma/migrations/migration_lock.toml
@@ -0,0 +1,3 @@
+# Please do not edit this file manually
+# It should be added in your version-control system (i.e. Git)
+provider = "mysql"
\ No newline at end of file
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
new file mode 100644
index 0000000..6a68a4b
--- /dev/null
+++ b/prisma/schema.prisma
@@ -0,0 +1,84 @@
+// 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 = ["referentialIntegrity"]
+}
+
+datasource db {
+ provider = "mysql"
+ url = env("DATABASE_URL")
+ referentialIntegrity = "prisma"
+}
+
+model Account {
+ id String @id @default(cuid())
+ userId String
+ type String
+ provider String
+ providerAccountId String
+ refresh_token String? @db.Text
+ access_token String? @db.Text
+ expires_at Int?
+ token_type String?
+ scope String?
+ id_token String? @db.Text
+ session_state String?
+ createdAt DateTime @default(now()) @map(name: "created_at")
+ updatedAt DateTime @default(now()) @map(name: "updated_at")
+
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
+
+ @@unique([provider, providerAccountId])
+ @@map(name: "accounts")
+}
+
+model Session {
+ id String @id @default(cuid())
+ sessionToken String @unique
+ userId String
+ expires DateTime
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
+
+ @@map(name: "sessions")
+}
+
+model User {
+ id String @id @default(cuid())
+ name String?
+ email String? @unique
+ emailVerified DateTime?
+ image String?
+ createdAt DateTime @default(now()) @map(name: "created_at")
+ updatedAt DateTime @default(now()) @map(name: "updated_at")
+
+ accounts Account[]
+ sessions Session[]
+ Post Post[]
+
+ @@map(name: "users")
+}
+
+model VerificationToken {
+ identifier String
+ token String @unique
+ expires DateTime
+
+ @@unique([identifier, token])
+ @@map(name: "verification_tokens")
+}
+
+model Post {
+ id String @id @default(cuid())
+ title String
+ content Json?
+ published Boolean @default(false)
+ createdAt DateTime @default(now()) @map(name: "created_at")
+ updatedAt DateTime @default(now()) @map(name: "updated_at")
+ authorId String
+
+ author User @relation(fields: [authorId], references: [id])
+
+ @@map(name: "posts")
+}
diff --git a/public/favicon.ico b/public/favicon.ico
new file mode 100644
index 0000000000000000000000000000000000000000..718d6fea4835ec2d246af9800eddb7ffb276240c
GIT binary patch
literal 25931
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
LrJugUA?W`A8`#=m
literal 0
HcmV?d00001
diff --git a/public/images/nextjs-icon-dark-background.png b/public/images/nextjs-icon-dark-background.png
new file mode 100644
index 0000000000000000000000000000000000000000..9d9775e4027a1c90cf89c52590c848eaa9761625
GIT binary patch
literal 36630
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