feat: initial commit

This commit is contained in:
shadcn 2022-10-26 17:18:06 +04:00
commit c2e5f0fc69
72 changed files with 5835 additions and 0 deletions

23
.env.example Normal file
View file

@ -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 <taxonomy@example.com>

6
.eslintrc.json Normal file
View file

@ -0,0 +1,6 @@
{
"extends": "next/core-web-vitals",
"rules": {
"@next/next/no-head-element": "off"
}
}

39
.gitignore vendored Normal file
View file

@ -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

1
.nvmrc Normal file
View file

@ -0,0 +1 @@
v14.17.3

21
LICENSE.md Normal file
View file

@ -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.

76
README.md Normal file
View file

@ -0,0 +1,76 @@
<p align="center">
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 3a3 3 0 0 0-3 3v12a3 3 0 0 0 3 3 3 3 0 0 0 3-3 3 3 0 0 0-3-3H6a3 3 0 0 0-3 3 3 3 0 0 0 3 3 3 3 0 0 0 3-3V6a3 3 0 0 0-3-3 3 3 0 0 0-3 3 3 3 0 0 0 3 3h12a3 3 0 0 0 3-3 3 3 0 0 0-3-3z"></path></svg>
<br/>
<h1 align="center">Taxonomy</h1>
<p align="center">An open source application built using the new router, server components and everything new in Next.js 13.</p>
</p>
> 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).

9
app/(auth)/layout.tsx Normal file
View file

@ -0,0 +1,9 @@
import "styles/globals.css"
interface AuthLayoutProps {
children: React.ReactNode
}
export default function RootLayout({ children }: AuthLayoutProps) {
return <div className="grid min-h-screen grid-cols-2">{children}</div>
}

37
app/(auth)/login/page.tsx Normal file
View file

@ -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 (
<div className="flex h-screen w-screen flex-col items-center justify-center">
<Link
href="/"
className="absolute top-8 left-8 inline-flex items-center justify-center rounded-lg border border-transparent bg-transparent py-2 px-3 text-center text-sm font-medium text-slate-900 hover:border-slate-100 hover:bg-slate-100 focus:z-10 focus:outline-none focus:ring-4 focus:ring-slate-200 dark:border-slate-600 dark:bg-slate-800 dark:text-slate-400 dark:hover:bg-slate-700 dark:hover:text-white dark:focus:ring-slate-700"
>
<>
<Icons.chevronLeft className="mr-2 h-4 w-4" />
Back
</>
</Link>
<div className="p-8">
<div className="mx-auto flex w-[350px] flex-col justify-center space-y-6">
<div className="flex flex-col space-y-2 text-center">
<Icons.logo className="mx-auto h-6 w-6" />
<h1 className="text-2xl font-bold">Welcome back</h1>
<p className="text-sm text-slate-500">
Enter your email to sign in to your account
</p>
</div>
<UserAuthForm />
<p className="px-8 text-center text-sm text-slate-500">
<Link href="/register" className="underline hover:text-brand">
Don&apos;t have an account? Sign Up
</Link>
</p>
</div>
</div>
</div>
)
}

View file

@ -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 (
<div className="grid h-screen w-screen grid-cols-2 flex-col items-center justify-center">
<Link
href="/login"
className="absolute top-8 right-8 inline-flex items-center justify-center rounded-lg border border-transparent bg-transparent py-2 px-3 text-center text-sm font-medium text-slate-900 hover:border-slate-100 hover:bg-slate-100 focus:z-10 focus:outline-none focus:ring-4 focus:ring-slate-200 dark:border-slate-600 dark:bg-slate-800 dark:text-slate-400 dark:hover:bg-slate-700 dark:hover:text-white dark:focus:ring-slate-700"
>
Login
</Link>
<div className="h-full bg-slate-100" />
<div className="p-8">
<div className="mx-auto flex w-[350px] flex-col justify-center space-y-6">
<div className="flex flex-col space-y-2 text-center">
<Icons.logo className="mx-auto h-6 w-6" />
<h1 className="text-2xl font-bold">Create an account</h1>
<p className="text-sm text-slate-500">
Enter your email below to create your account
</p>
</div>
<UserAuthForm />
<p className="px-8 text-center text-sm text-slate-500">
By clicking continue, you agree to our{" "}
<Link href="/terms" className="underline hover:text-brand">
Terms of Service
</Link>{" "}
and{" "}
<Link href="/privacy" className="underline hover:text-brand">
Privacy Policy
</Link>
.
</p>
</div>
</div>
</div>
)
}

View file

@ -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 (
<>
<div className="flex h-screen overflow-hidden">
<aside className="hidden w-14 flex-col border-r border-slate-100 bg-slate-50 py-4 md:flex lg:w-56 lg:flex-shrink-0 lg:px-4">
<div className="flex flex-1 flex-col space-y-4">
<DashboardBranding />
<DashboardNav />
</div>
<UserAccountNav
user={{
name: user.name,
image: user.image,
email: user.email,
}}
/>
</aside>
<main className="flex w-0 flex-1 flex-col overflow-hidden px-12 py-10">
{children}
</main>
</div>
</>
)
}

View file

@ -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 (
<DashboardShell>
<DashboardHeader heading="Posts" text="Create and manage posts.">
<PostCreateButton />
</DashboardHeader>
<div className="divide-y divide-neutral-200 rounded-md border border-slate-200">
<PostItem.Skeleton />
<PostItem.Skeleton />
<PostItem.Skeleton />
<PostItem.Skeleton />
<PostItem.Skeleton />
</div>
</DashboardShell>
)
}

View file

@ -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 (
<DashboardShell>
<DashboardHeader heading="Posts" text="Create and manage posts.">
<PostCreateButton />
</DashboardHeader>
<div>
{posts?.length ? (
<div className="divide-y divide-neutral-200 rounded-md border border-slate-200">
{posts.map((post) => (
<PostItem key={post.id} post={post} />
))}
</div>
) : (
<EmptyPlaceholder>
<EmptyPlaceholder.Icon name="post" />
<EmptyPlaceholder.Title>No posts created</EmptyPlaceholder.Title>
<EmptyPlaceholder.Description>
You don&apos;t have any posts yet. Start creating content.
</EmptyPlaceholder.Description>
<PostCreateButton className="border-slate-200 bg-white text-brand-900 hover:bg-slate-100 focus:outline-none focus:ring-2 focus:ring-slate-500 focus:ring-offset-2" />
</EmptyPlaceholder>
)}
</div>
</DashboardShell>
)
}

View file

@ -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 (
<DashboardShell>
<DashboardHeader
heading="Settings"
text="Manage account and website settings."
/>
<div className="grid gap-10">
<Card.Skeleton />
<Card.Skeleton />
</div>
</DashboardShell>
)
}

View file

@ -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 (
<DashboardShell>
<DashboardHeader
heading="Settings"
text="Manage account and website settings."
/>
<div className="grid gap-10">
<UserNameForm user={{ id: user.id, name: user.name }} />
</div>
</DashboardShell>
)
}

View file

@ -0,0 +1,21 @@
import Link from "next/link"
import { EmptyPlaceholder } from "@/components/empty-placeholder"
export default function NotFound() {
return (
<EmptyPlaceholder>
<EmptyPlaceholder.Icon name="warning" />
<EmptyPlaceholder.Title>Uh oh! Not Found</EmptyPlaceholder.Title>
<EmptyPlaceholder.Description>
This post cound not be found. Please try again.
</EmptyPlaceholder.Description>
<Link
href="/dashboard"
className="relative inline-flex h-9 items-center rounded-md border border-slate-200 bg-white px-4 py-2 text-sm font-medium text-brand-900 hover:bg-slate-100 focus:outline-none focus:ring-2 focus:ring-slate-500 focus:ring-offset-2"
>
Go to Dashboard
</Link>
</EmptyPlaceholder>
)
}

View file

@ -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 (
<Editor
post={{
id: post.id,
title: post.title,
content: post.content,
published: post.published,
}}
/>
)
}

View file

@ -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 (
<div className="container mx-auto grid items-start gap-10 py-8 px-8">
{children}
</div>
)
}

View file

@ -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 (
<div className="mx-auto w-full px-4">
<header className="mx-auto flex max-w-[1440px] items-center justify-between py-4">
<Link href="/" className="flex items-center space-x-2">
<Icons.logo />
<span className="font-bold">Taxonomy</span>
</Link>
<div>
<Link href="/login">Login</Link>
</div>
</header>
<main>{children}</main>
</div>
)
}

33
app/(marketing)/page.tsx Normal file
View file

@ -0,0 +1,33 @@
import { Icons } from "@/components/icons"
import Image from "next/image"
import Link from "next/link"
export default function IndexPage() {
return (
<section className="mx-auto grid max-w-[1100px] grid-cols-[1fr_380px] items-center gap-12 py-12">
<div>
<h1 className="text-6xl font-black leading-[1.1]">
Publishing Platform for Everyone
</h1>
<p className="my-4 max-w-[85%] text-xl leading-8 text-slate-500">
A Next.js 13 application built using layouts, server components and
everything new in React 18.
</p>
<Link
href="/login"
className="relative inline-flex h-11 items-center rounded-md border border-transparent bg-brand-500 px-8 py-2 font-medium text-white hover:bg-brand-400 focus:outline-none focus:ring-2 focus:ring-brand-500 focus:ring-offset-2"
>
Get Started
<Icons.arrowRight className="ml-2 h-4 w-4" />
</Link>
</div>
<Image
src="/images/nextjs-icon-dark-background.png"
width={380}
height={380}
alt="Next.js logo"
priority
/>
</section>
)
}

9
app/head.tsx Normal file
View file

@ -0,0 +1,9 @@
export default function Head() {
return (
<>
<title>Taxonomy</title>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width" />
</>
)
}

18
app/layout.tsx Normal file
View file

@ -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 (
<html>
<body className="min-h-screen">
{children}
<Toaster position="bottom-right" />
</body>
</html>
)
}

View file

@ -0,0 +1,10 @@
import { Icons } from "./icons"
export function DashboardBranding() {
return (
<header className="flex items-center space-x-2 px-3">
<Icons.logo />
<span className="font-bold">Taxonomy</span>
</header>
)
}

View file

@ -0,0 +1,23 @@
interface DashboardHeaderProps {
heading: string
text?: string
children?: React.ReactNode
}
export function DashboardHeader({
heading,
text,
children,
}: DashboardHeaderProps) {
return (
<div className="flex justify-between px-2">
<div className="grid gap-1">
<h1 className="text-xl font-bold tracking-wide text-black">
{heading}
</h1>
{text && <p className="text-sm text-neutral-500">{text}</p>}
</div>
{children}
</div>
)
}

View file

@ -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 (
<nav className="grid items-start gap-1">
{navigationItems.map((navigationItem, index) => (
<Link
key={index}
href={navigationItem.disabled ? "/" : navigationItem.href}
>
<span
className={clsx(
"group flex items-center rounded-md px-3 py-2 text-sm font-medium text-slate-600 hover:bg-slate-100",
path === navigationItem.href ? "bg-slate-200" : "transparent",
navigationItem.disabled && "cursor-not-allowed opacity-50"
)}
>
<navigationItem.icon className="mr-2 h-4 w-4" />
<span>{navigationItem.title}</span>
</span>
</Link>
))}
</nav>
)
}

View file

@ -0,0 +1,16 @@
import * as React from "react"
import { cn } from "@/lib/utils"
interface DashboardShellProps extends React.HTMLAttributes<HTMLDivElement> {}
export function DashboardShell({
children,
className,
...props
}: DashboardShellProps) {
return (
<div className={cn("grid items-start gap-8", className)} {...props}>
{children}
</div>
)
}

164
components/editor.tsx Normal file
View file

@ -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<Post, "id" | "title" | "content" | "published">
}
type FormData = z.infer<typeof postPatchSchema>
export function Editor({ post }: EditorProps) {
const { register, handleSubmit } = useForm<FormData>({
resolver: zodResolver(postPatchSchema),
})
const ref = React.useRef<EditorJS>()
const [isSaving, setIsSaving] = React.useState<boolean>(false)
const [isMounted, setIsMounted] = React.useState<boolean>(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 (
<form onSubmit={handleSubmit(onSubmit)}>
<div className="grid w-full gap-10">
<div className="flex w-full items-center justify-between">
<div className="flex items-center space-x-10">
<Link
href="/dashboard"
className="inline-flex items-center rounded-lg border border-transparent bg-transparent py-2 pl-3 pr-5 text-sm font-medium text-slate-900 hover:border-slate-100 hover:bg-slate-100 focus:z-10 focus:outline-none focus:ring-4 focus:ring-slate-200 dark:border-slate-600 dark:bg-slate-800 dark:text-slate-400 dark:hover:bg-slate-700 dark:hover:text-white dark:focus:ring-slate-700"
>
<>
<Icons.chevronLeft className="mr-2 h-4 w-4" />
Back
</>
</Link>
<p className="text-sm text-slate-500">
{post.published ? "Published" : "Draft"}
</p>
</div>
<button
type="submit"
className="relative inline-flex h-9 items-center rounded-md border border-transparent bg-brand-500 px-4 py-2 text-sm font-medium text-white hover:bg-brand-400 focus:outline-none focus:ring-2 focus:ring-brand-500 focus:ring-offset-2"
>
{isSaving && (
<Icons.spinner className="mr-2 h-4 w-4 animate-spin" />
)}
<span>Save</span>
</button>
</div>
<div className="prose prose-stone mx-auto w-[800px]">
<TextareaAutosize
autoFocus
name="title"
id="title"
defaultValue={post.title}
placeholder="Post title"
className="w-full resize-none appearance-none overflow-hidden text-5xl font-bold focus:outline-none"
{...register("title")}
/>
<div id="editor" className="min-h-[500px]" />
<p className="text-sm text-gray-500">
Use{" "}
<kbd className="rounded-md border bg-slate-50 px-1 text-xs uppercase">
Tab
</kbd>{" "}
to open the command menu.
</p>
</div>
</div>
</form>
)
}

View file

@ -0,0 +1,79 @@
import * as React from "react"
import { cn } from "@/lib/utils"
import { Icons } from "@/components/icons"
interface EmptyPlaceholderProps extends React.HTMLAttributes<HTMLDivElement> {}
export function EmptyPlaceholder({
className,
children,
...props
}: EmptyPlaceholderProps) {
return (
<div
className={cn(
"flex min-h-[400px] flex-col items-center justify-center rounded-md border border-dashed p-8 text-center animate-in fade-in-50",
className
)}
{...props}
>
<div className="mx-auto flex max-w-[420px] flex-col items-center justify-center text-center">
{children}
</div>
</div>
)
}
interface EmptyPlaceholderIconProps
extends Partial<React.SVGProps<SVGSVGElement>> {
name: keyof typeof Icons
}
EmptyPlaceholder.Icon = function EmptyPlaceHolderIcon({
name,
className,
...props
}: EmptyPlaceholderIconProps) {
const Icon = Icons[name]
if (!Icon) {
return null
}
return (
<div className="flex h-20 w-20 items-center justify-center rounded-full bg-slate-100">
<Icon className={cn("h-10 w-10", className)} {...props} />
</div>
)
}
interface EmptyPlacholderTitleProps
extends React.HTMLAttributes<HTMLHeadingElement> {}
EmptyPlaceholder.Title = function EmptyPlaceholderTitle({
className,
...props
}: EmptyPlacholderTitleProps) {
return (
<h2 className={cn("mt-6 text-xl font-semibold", className)} {...props} />
)
}
interface EmptyPlacholderDescriptionProps
extends React.HTMLAttributes<HTMLParagraphElement> {}
EmptyPlaceholder.Description = function EmptyPlaceholderDescription({
className,
...props
}: EmptyPlacholderDescriptionProps) {
return (
<p
className={cn(
"mt-3 mb-8 text-center text-sm font-normal leading-6 text-slate-700",
className
)}
{...props}
/>
)
}

34
components/icons.tsx Normal file
View file

@ -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,
}

View file

@ -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<Pick<Post, "id">> {
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<HTMLButtonElement> {}
export function PostCreateButton({
className,
...props
}: PostCreateButtonProps) {
const router = useRouter()
const [isLoading, setIsLoading] = React.useState<boolean>(false)
async function onClick() {
setIsLoading(!isLoading)
const post = await createPost()
router.push(`/editor/${post.id}`)
}
return (
<button
onClick={onClick}
className={cn(
"relative inline-flex h-9 items-center rounded-md border border-transparent bg-brand-500 px-4 py-2 text-sm font-medium text-white hover:bg-brand-400 focus:outline-none focus:ring-2 focus:ring-brand-500 focus:ring-offset-2",
{
"cursor-not-allowed opacity-60": isLoading,
},
className
)}
disabled={isLoading}
{...props}
>
{isLoading ? (
<Icons.spinner className="mr-2 h-4 w-4 animate-spin" />
) : (
<Icons.add className="mr-2 h-4 w-4" />
)}
New post
</button>
)
}

42
components/post-item.tsx Normal file
View file

@ -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<Post, "id" | "title" | "published" | "createdAt">
}
export function PostItem({ post }: PostItemProps) {
return (
<div className="flex items-center justify-between p-4">
<div className="grid gap-1">
<Link
href={`/editor/${post.id}`}
className="font-semibold hover:underline"
>
{post.title}
</Link>
<div>
<p className="text-sm text-slate-600">
{formatDate(post.createdAt?.toDateString())}
</p>
</div>
</div>
<PostOperations post={{ id: post.id, title: post.title }} />
{/* <PostDeleteButton post={{ id: post.id, title: post.title }} /> */}
</div>
)
}
PostItem.Skeleton = function PostItemSkeleton() {
return (
<div className="p-4">
<div className="space-y-3">
<div className="h-5 w-2/5 animate-pulse rounded-lg bg-slate-100"></div>
<div className="h-4 w-4/5 animate-pulse rounded-lg bg-slate-100"></div>
</div>
</div>
)
}

View file

@ -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<Post, "id" | "title">
}
export function PostOperations({ post }: PostOperationsProps) {
const router = useRouter()
const [showDeleteAlert, setShowDeleteAlert] = React.useState<boolean>(false)
const [isDeleteLoading, setIsDeleteLoading] = React.useState<boolean>(false)
return (
<>
<DropdownMenu>
<DropdownMenu.Trigger className="flex h-8 w-8 items-center justify-center rounded-md border transition-colors hover:bg-slate-50">
<Icons.ellipsis className="h-4 w-4" />
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content>
<DropdownMenu.Item>
<Link href={`/editor/${post.id}`} className="flex w-full">
Edit
</Link>
</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item
className="flex cursor-pointer items-center text-red-600 focus:bg-red-50"
onSelect={() => setShowDeleteAlert(true)}
>
Delete
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu>
<Alert open={showDeleteAlert} onOpenChange={setShowDeleteAlert}>
<Alert.Content>
<Alert.Header>
<Alert.Title>
Are you sure you want to delete this post?
</Alert.Title>
<Alert.Description>This action cannot be undone.</Alert.Description>
</Alert.Header>
<Alert.Footer>
<Alert.Cancel>Cancel</Alert.Cancel>
<Alert.Action
onClick={async (event) => {
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 ? (
<Icons.spinner className="mr-2 h-4 w-4 animate-spin" />
) : (
<Icons.trash className="mr-2 h-4 w-4" />
)}
<span>Delete</span>
</Alert.Action>
</Alert.Footer>
</Alert.Content>
</Alert>
</>
)
}

112
components/ui/alert.tsx Normal file
View file

@ -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 <AlertDialogPrimitives.Root {...props} />
}
Alert.Trigger = React.forwardRef<
HTMLButtonElement,
AlertDialogPrimitives.AlertDialogTriggerProps
>(function AlertTrigger({ ...props }, ref) {
return <AlertDialogPrimitives.Trigger {...props} ref={ref} />
})
Alert.Portal = AlertDialogPrimitives.Portal
Alert.Content = React.forwardRef<
HTMLDivElement,
AlertDialogPrimitives.AlertDialogContentProps
>(function AlertContent({ className, ...props }, ref) {
return (
<Alert.Portal>
<AlertDialogPrimitives.Overlay className="fixed inset-0 z-20 bg-black/50 opacity-100 transition-opacity animate-in fade-in">
<div className="fixed inset-0 z-40 flex items-center justify-center">
<AlertDialogPrimitives.Content
ref={ref}
className={cn(
"fixed z-50 grid w-[95vw] max-w-md scale-100 gap-4 rounded-lg bg-white p-6 opacity-100 animate-in fade-in-90 zoom-in-90 focus:outline-none focus-visible:ring focus-visible:ring-purple-500 focus-visible:ring-opacity-75 md:w-full",
className
)}
{...props}
/>
</div>
</AlertDialogPrimitives.Overlay>
</Alert.Portal>
)
})
type AlertHeaderProps = React.HTMLAttributes<HTMLDivElement>
Alert.Header = function AlertHeader({ className, ...props }: AlertHeaderProps) {
return <div className={cn("grid gap-1", className)} {...props} />
}
Alert.Title = React.forwardRef<
HTMLHeadingElement,
AlertDialogPrimitives.AlertDialogTitleProps
>(function AlertTitle({ className, ...props }, ref) {
return (
<AlertDialogPrimitives.Title
ref={ref}
className={cn("text-lg font-semibold text-slate-900", className)}
{...props}
/>
)
})
Alert.Description = React.forwardRef<
HTMLParagraphElement,
AlertDialogPrimitives.AlertDialogDescriptionProps
>(function AlertDescription({ className, ...props }, ref) {
return (
<AlertDialogPrimitives.Description
ref={ref}
className={cn("text-sm text-neutral-500", className)}
{...props}
/>
)
})
Alert.Footer = function AlertFooter({ className, ...props }: AlertHeaderProps) {
return (
<div className={cn("flex justify-end space-x-2", className)} {...props} />
)
}
Alert.Cancel = React.forwardRef<
HTMLButtonElement,
AlertDialogPrimitives.AlertDialogCancelProps
>(function AlertCancel({ className, ...props }, ref) {
return (
<AlertDialogPrimitives.Cancel
ref={ref}
className={cn(
"relative inline-flex h-9 items-center rounded-md border border-slate-200 bg-white px-4 py-2 text-sm font-medium text-brand-900 hover:bg-slate-100 focus:outline-none focus:ring-2 focus:ring-slate-500 focus:ring-offset-2",
className
)}
{...props}
/>
)
})
Alert.Action = React.forwardRef<
HTMLButtonElement,
AlertDialogPrimitives.AlertDialogActionProps
>(function AlertAction({ className, ...props }, ref) {
return (
<AlertDialogPrimitives.Action
ref={ref}
className={cn(
"relative inline-flex h-9 items-center rounded-md border border-transparent bg-brand-500 px-4 py-2 text-sm font-medium text-white hover:bg-brand-400 focus:outline-none focus:ring-2 focus:ring-brand-500 focus:ring-offset-2",
className
)}
{...props}
/>
)
})

40
components/ui/avatar.tsx Normal file
View file

@ -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 (
<AvatarPrimitive.Root
className={cn(
"flex h-[48px] w-[48px] items-center justify-center overflow-hidden rounded-full bg-slate-100",
className
)}
{...props}
/>
)
}
type AvatarImageProps = AvatarPrimitive.AvatarImageProps
Avatar.Image = function AvatarImage({ className, ...props }: AvatarImageProps) {
return <AvatarPrimitive.Image className={cn("", className)} {...props} />
}
Avatar.Fallback = function AvatarFallback({
className,
children,
...props
}: AvatarPrimitive.AvatarFallbackProps) {
return (
<AvatarPrimitive.Fallback
delayMs={500}
className={cn("", className)}
{...props}
>
{children}
</AvatarPrimitive.Fallback>
)
}

66
components/ui/card.tsx Normal file
View file

@ -0,0 +1,66 @@
import { cn } from "@/lib/utils"
interface CardProps extends React.HTMLAttributes<HTMLDivElement> {}
export function Card({ className, ...props }: CardProps) {
return (
<div
className={cn("overflow-hidden rounded-lg border", className)}
{...props}
/>
)
}
interface CardHeaderProps extends React.HTMLAttributes<HTMLDivElement> {}
Card.Header = function CardHeader({ className, ...props }: CardHeaderProps) {
return <div className={cn("grid gap-1 p-6", className)} {...props} />
}
interface CardContentProps extends React.HTMLAttributes<HTMLDivElement> {}
Card.Content = function CardContent({ className, ...props }: CardContentProps) {
return <div className={cn("px-6 pb-4", className)} {...props} />
}
interface CardFooterProps extends React.HTMLAttributes<HTMLDivElement> {}
Card.Footer = function CardFooter({ className, ...props }: CardFooterProps) {
return (
<div
className={cn("border-t bg-slate-50 px-6 py-4", className)}
{...props}
/>
)
}
interface CardTitleProps extends React.HTMLAttributes<HTMLHeadingElement> {}
Card.Title = function CardTitle({ className, ...props }: CardTitleProps) {
return <h4 className={cn("text-lg font-medium", className)} {...props} />
}
interface CardDescriptionProps
extends React.HTMLAttributes<HTMLParagraphElement> {}
Card.Description = function CardDescription({
className,
...props
}: CardDescriptionProps) {
return <p className={cn("text-sm text-gray-600", className)} {...props} />
}
Card.Skeleton = function CardSeleton() {
return (
<Card>
<Card.Header className="gap-2">
<div className="h-5 w-1/5 animate-pulse rounded-lg bg-slate-100"></div>
<div className="h-4 w-4/5 animate-pulse rounded-lg bg-slate-100"></div>
</Card.Header>
<Card.Content className="h-10" />
<Card.Footer>
<div className="h-8 w-[120px] animate-pulse rounded-lg bg-slate-200"></div>
</Card.Footer>
</Card>
)
}

View file

@ -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 <DropdownMenuPrimitive.Root {...props} />
}
DropdownMenu.Trigger = React.forwardRef<
HTMLButtonElement,
DropdownMenuPrimitive.DropdownMenuTriggerProps
>(function DropdownMenuTrigger({ ...props }, ref) {
return <DropdownMenuPrimitive.Trigger {...props} ref={ref} />
})
DropdownMenu.Portal = DropdownMenuPrimitive.Portal
DropdownMenu.Content = React.forwardRef<
HTMLDivElement,
DropdownMenuPrimitive.MenuContentProps
>(function DropdownMenuContent({ className, ...props }, ref) {
return (
<DropdownMenuPrimitive.Content
ref={ref}
align="end"
className={cn(
"overflow-hidden rounded-md bg-white shadow-md animate-in slide-in-from-top-1 md:w-32",
className
)}
{...props}
/>
)
})
DropdownMenu.Item = React.forwardRef<
HTMLDivElement,
DropdownMenuPrimitive.DropdownMenuItemProps
>(function DropdownMenuItem({ className, ...props }, ref) {
return (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"flex cursor-default select-none items-center py-2 px-3 text-sm text-slate-500 outline-none focus:bg-slate-50 focus:text-black",
className
)}
{...props}
/>
)
})
DropdownMenu.Separator = React.forwardRef<
HTMLDivElement,
DropdownMenuPrimitive.DropdownMenuSeparatorProps
>(function DropdownMenuItem({ className, ...props }, ref) {
return (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn("h-px bg-slate-200", className)}
{...props}
/>
)
})

89
components/ui/toast.tsx Normal file
View file

@ -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<HTMLDivElement> {
visible: boolean
}
export function Toast({ visible, className, ...props }: ToastProps) {
return (
<div
className={cn(
"min-h-16 mb-2 flex w-[350px] flex-col items-start gap-1 rounded-md bg-white px-6 py-4 shadow-lg",
visible && "animate-in slide-in-from-bottom-5",
className
)}
{...props}
/>
)
}
interface ToastIconProps extends Partial<React.SVGProps<SVGSVGElement>> {
name: keyof typeof Icons
}
Toast.Icon = function ToastIcon({ name, className, ...props }: ToastIconProps) {
const Icon = Icons[name]
if (!Icon) {
return null
}
return (
<div className="flex h-20 w-20 items-center justify-center rounded-full bg-slate-100">
<Icon className={cn("h-10 w-10", className)} {...props} />
</div>
)
}
interface ToastTitleProps extends React.HTMLAttributes<HTMLHeadingElement> {}
Toast.Title = function ToastTitle({ className, ...props }: ToastTitleProps) {
return <p className={cn("text-sm font-medium", className)} {...props} />
}
interface ToastDescriptionProps
extends React.HTMLAttributes<HTMLParagraphElement> {}
Toast.Description = function ToastDescription({
className,
...props
}: ToastDescriptionProps) {
return <p className={cn("text-sm opacity-80", className)} {...props} />
}
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 }) => (
<Toast
visible={visible}
className={cn({
"bg-red-600 text-white": type === "error",
"bg-black text-white": type === "success",
})}
>
<Toast.Title>{title}</Toast.Title>
{message && <Toast.Description>{message}</Toast.Description>}
</Toast>
),
{ duration }
)
}

View file

@ -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<HTMLDivElement> {
user: Pick<User, "name" | "image" | "email">
}
export function UserAccountNav({ user }: UserAccountNavProps) {
return (
<DropdownMenu>
<DropdownMenu.Trigger className="flex items-center gap-2 overflow-hidden rounded-md border bg-white p-2 px-2 hover:bg-slate-100 focus:ring-2 focus:ring-brand-900 focus:ring-offset-2 focus-visible:outline-none">
<UserAvatar user={{ name: user.name, image: user.image }} />
<div className="flex flex-1 flex-col items-start">
{user.name && <p className="text-sm font-medium">{user.name}</p>}
<p className="rounded-md bg-brand px-2 py-[2px] text-[10px] uppercase text-white">
Pro
</p>
</div>
<Icons.ellipsis className="h-4 w-4" />
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content className="md:w-[240px]" align="start">
<div className="flex items-center justify-start gap-2 p-4">
<div className="flex flex-col leading-none">
{user.name && <p className="font-medium">{user.name}</p>}
{user.email && (
<p className="w-[200px] truncate text-sm text-slate-600">
{user.email}
</p>
)}
</div>
</div>
<DropdownMenu.Separator />
<DropdownMenu.Item>
<Link href="/dashboard" className="w-full">
Dashboard
</Link>
</DropdownMenu.Item>
<DropdownMenu.Item>
<Link href="/dashboard/settings" className="w-full">
Settings
</Link>
</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item>
<Link
href="https://github.com/shadcn/taxonomy"
className="w-full"
target="_blank"
>
GitHub
</Link>
</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item
className="cursor-pointer"
onSelect={(event) => {
event.preventDefault()
signOut({
callbackUrl: `${window.location.origin}/login`,
})
}}
>
Sign out
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu>
)
}

View file

@ -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<HTMLDivElement> {}
type FormData = z.infer<typeof userAuthSchema>
export function UserAuthForm({ className, ...props }: UserAuthFormProps) {
const {
register,
handleSubmit,
formState: { errors },
} = useForm<FormData>({
resolver: zodResolver(userAuthSchema),
})
const [isLoading, setIsLoading] = React.useState<boolean>(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 (
<div className={cn("grid gap-6", className)} {...props}>
<form onSubmit={handleSubmit(onSubmit)}>
<div className="grid gap-2">
<div className="grid gap-1">
<label className="sr-only" htmlFor="email">
Email
</label>
<input
id="email"
placeholder="name@example.com"
className="my-0 mb-2 block h-9 w-full rounded-md border border-slate-300 py-2 px-3 text-sm placeholder:text-slate-400 hover:border-slate-400 focus:border-neutral-300 focus:outline-none focus:ring-2 focus:ring-neutral-800 focus:ring-offset-1"
type="email"
autoCapitalize="none"
autoComplete="email"
autoCorrect="off"
name="email"
disabled={isLoading}
{...register("email")}
/>
{errors?.email && (
<p className="px-1 text-xs text-red-600">
{errors.email.message}
</p>
)}
</div>
<button className="inline-flex w-full items-center justify-center rounded-lg bg-[#24292F] px-5 py-2.5 text-center text-sm font-medium text-white hover:bg-[#24292F]/90 focus:outline-none focus:ring-4 focus:ring-[#24292F]/50 dark:hover:bg-[#050708]/30 dark:focus:ring-slate-500">
{isLoading && (
<Icons.spinner className="mr-2 h-4 w-4 animate-spin" />
)}
Sign In with Email
</button>
</div>
</form>
<div className="relative">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-slate-300"></div>
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-white px-2 text-slate-500">Or continue with</span>
</div>
</div>
<button
type="button"
className="inline-flex w-full items-center justify-center rounded-lg border bg-white px-5 py-2.5 text-center text-sm font-medium text-black hover:bg-slate-100 focus:outline-none focus:ring-4 focus:ring-[#24292F]/50 dark:hover:bg-[#050708]/30 dark:focus:ring-slate-500"
onClick={() => signIn("github")}
>
<svg
className="mr-2 h-4 w-4"
aria-hidden="true"
focusable="false"
data-prefix="fab"
data-icon="github"
role="img"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 496 512"
>
<path
fill="currentColor"
d="M165.9 397.4c0 2-2.3 3.6-5.2 3.6-3.3 .3-5.6-1.3-5.6-3.6 0-2 2.3-3.6 5.2-3.6 3-.3 5.6 1.3 5.6 3.6zm-31.1-4.5c-.7 2 1.3 4.3 4.3 4.9 2.6 1 5.6 0 6.2-2s-1.3-4.3-4.3-5.2c-2.6-.7-5.5 .3-6.2 2.3zm44.2-1.7c-2.9 .7-4.9 2.6-4.6 4.9 .3 2 2.9 3.3 5.9 2.6 2.9-.7 4.9-2.6 4.6-4.6-.3-1.9-3-3.2-5.9-2.9zM244.8 8C106.1 8 0 113.3 0 252c0 110.9 69.8 205.8 169.5 239.2 12.8 2.3 17.3-5.6 17.3-12.1 0-6.2-.3-40.4-.3-61.4 0 0-70 15-84.7-29.8 0 0-11.4-29.1-27.8-36.6 0 0-22.9-15.7 1.6-15.4 0 0 24.9 2 38.6 25.8 21.9 38.6 58.6 27.5 72.9 20.9 2.3-16 8.8-27.1 16-33.7-55.9-6.2-112.3-14.3-112.3-110.5 0-27.5 7.6-41.3 23.6-58.9-2.6-6.5-11.1-33.3 2.6-67.9 20.9-6.5 69 27 69 27 20-5.6 41.5-8.5 62.8-8.5s42.8 2.9 62.8 8.5c0 0 48.1-33.6 69-27 13.7 34.7 5.2 61.4 2.6 67.9 16 17.7 25.8 31.5 25.8 58.9 0 96.5-58.9 104.2-114.8 110.5 9.2 7.9 17 22.9 17 46.4 0 33.7-.3 75.4-.3 83.6 0 6.5 4.6 14.4 17.3 12.1C428.2 457.8 496 362.9 496 252 496 113.3 383.5 8 244.8 8zM97.2 352.9c-1.3 1-1 3.3 .7 5.2 1.6 1.6 3.9 2.3 5.2 1 1.3-1 1-3.3-.7-5.2-1.6-1.6-3.9-2.3-5.2-1zm-10.8-8.1c-.7 1.3 .3 2.9 2.3 3.9 1.6 1 3.6 .7 4.3-.7 .7-1.3-.3-2.9-2.3-3.9-2-.6-3.6-.3-4.3 .7zm32.4 35.6c-1.6 1.3-1 4.3 1.3 6.2 2.3 2.3 5.2 2.6 6.5 1 1.3-1.3 .7-4.3-1.3-6.2-2.2-2.3-5.2-2.6-6.5-1zm-11.4-14.7c-1.6 1-1.6 3.6 0 5.9 1.6 2.3 4.3 3.3 5.6 2.3 1.6-1.3 1.6-3.9 0-6.2-1.4-2.3-4-3.3-5.6-2z"
></path>
</svg>
Github
</button>
</div>
)
}

View file

@ -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<User, "image" | "name">
}
export function UserAvatar({ user, ...props }: UserAvatarProps) {
return (
<Avatar {...props}>
<Avatar.Image alt="Picture" src={user.image} />
<Avatar.Fallback>
<span className="sr-only">{user.name}</span>
<Icons.user className="h-6 w-6" />
</Avatar.Fallback>
</Avatar>
)
}

View file

@ -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<HTMLFormElement> {
user: Pick<User, "id" | "name">
}
type FormData = z.infer<typeof userNameSchema>
export function UserNameForm({ user, className, ...props }: UserNameFormProps) {
const router = useRouter()
const {
handleSubmit,
register,
formState: { errors },
} = useForm<FormData>({
resolver: zodResolver(userNameSchema),
defaultValues: {
name: user.name,
},
})
const [isSaving, setIsSaving] = React.useState<boolean>(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 (
<form
className={cn(className)}
onSubmit={handleSubmit(onSubmit)}
{...props}
>
<Card>
<Card.Header>
<Card.Title>Your Name</Card.Title>
<Card.Description>
Please enter your full name or a display name you are comfortable
with.
</Card.Description>
</Card.Header>
<Card.Content>
<div className="grid gap-1">
<label className="sr-only" htmlFor="name">
Name
</label>
<input
id="name"
className="my-0 mb-2 block h-9 w-[350px] rounded-md border border-slate-300 py-2 px-3 text-sm placeholder:text-slate-400 hover:border-slate-400 focus:border-neutral-300 focus:outline-none focus:ring-2 focus:ring-neutral-800 focus:ring-offset-1"
size={32}
name="name"
{...register("name")}
/>
{errors?.name && (
<p className="px-1 text-xs text-red-600">{errors.name.message}</p>
)}
</div>
</Card.Content>
<Card.Footer>
<button
type="submit"
className={cn(
"relative inline-flex h-9 items-center rounded-md border border-transparent bg-brand-500 px-4 py-2 text-sm font-medium text-white hover:bg-brand-400 focus:outline-none focus:ring-2 focus:ring-brand-500 focus:ring-offset-2",
{
"cursor-not-allowed opacity-60": isSaving,
},
className
)}
disabled={isSaving}
>
{isSaving && (
<Icons.spinner className="mr-2 h-4 w-4 animate-spin" />
)}
<span>Save</span>
</button>
</Card.Footer>
</Card>
</form>
)
}

View file

@ -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)
}
}

View file

@ -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)
}
}

View file

@ -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()
}
}
}

View file

@ -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()
}
}
}

View file

@ -0,0 +1,24 @@
import type { NextApiHandler, NextApiRequest, NextApiResponse } from "next"
import * as z from "zod"
import type { ZodSchema } from "zod"
export function withValidation<T extends ZodSchema>(
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()
}
}
}

18
lib/db.ts Normal file
View file

@ -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

7
lib/models.ts Normal file
View file

@ -0,0 +1,7 @@
import { Post } from ".prisma/client"
export type SelectFields<T> = {
[K in keyof T]?: true
}
export type PostItem = Pick<Post, "id" | "title" | "published">

15
lib/session.ts Normal file
View file

@ -0,0 +1,15 @@
import { Session } from "next-auth"
export async function getSession(cookie: string): Promise<Session> {
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
}

15
lib/utils.ts Normal file
View file

@ -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",
})
}

5
lib/validations/auth.ts Normal file
View file

@ -0,0 +1,5 @@
import * as z from "zod"
export const userAuthSchema = z.object({
email: z.string().email(),
})

8
lib/validations/post.ts Normal file
View file

@ -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(),
})

5
lib/validations/user.ts Normal file
View file

@ -0,0 +1,5 @@
import * as z from "zod"
export const userNameSchema = z.object({
name: z.string().min(3).max(32),
})

22
middleware.ts Normal file
View file

@ -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*"] }

14
next.config.js Normal file
View file

@ -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

68
package.json Normal file
View file

@ -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"
}
}

0
pages/.gitkeep Normal file
View file

View file

@ -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)

View file

@ -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))
)

64
pages/api/posts/index.ts Normal file
View file

@ -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))

View file

@ -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)))

6
postcss.config.js Normal file
View file

@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View file

@ -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;

View file

@ -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"

84
prisma/schema.prisma Normal file
View file

@ -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")
}

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

4
public/vercel.svg Normal file
View 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

3
styles/globals.css Normal file
View file

@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

27
tailwind.config.js Normal file
View file

@ -0,0 +1,27 @@
const { colors } = require("tailwindcss/colors")
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ["./app/**/*.{ts,tsx}", "./components/**/*.{ts,tsx}"],
theme: {
extend: {
colors: {
...colors,
brand: {
50: "#f3f3f3",
100: "#e7e7e7",
200: "#c4c4c4",
300: "#a0a0a0",
400: "#585858",
500: "#111111",
600: "#0f0f0f",
700: "#0d0d0d",
800: "#0a0a0a",
900: "#080808",
DEFAULT: "#111111",
},
},
},
},
plugins: [require("tailwindcss-animate"), require("@tailwindcss/typography")],
}

48
tsconfig.json Normal file
View file

@ -0,0 +1,48 @@
{
"compilerOptions": {
"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",
"incremental": true,
"baseUrl": ".",
"paths": {
"@/components/*": [
"components/*"
],
"@/lib/*": [
"lib/*"
],
"@/styles/*": [
"styles/*"
]
},
"plugins": [
{
"name": "next"
}
]
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}

9
types/next-auth.d.ts vendored Normal file
View file

@ -0,0 +1,9 @@
import { User } from "next-auth"
declare module "next-auth" {
interface Session {
user: User & {
id: string
}
}
}

3124
yarn.lock Normal file

File diff suppressed because it is too large Load diff