mirror of
https://github.com/shadcn-ui/taxonomy
synced 2026-05-24 09:48:32 +00:00
feat: initial commit
This commit is contained in:
commit
c2e5f0fc69
72 changed files with 5835 additions and 0 deletions
23
.env.example
Normal file
23
.env.example
Normal 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
6
.eslintrc.json
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"extends": "next/core-web-vitals",
|
||||
"rules": {
|
||||
"@next/next/no-head-element": "off"
|
||||
}
|
||||
}
|
||||
39
.gitignore
vendored
Normal file
39
.gitignore
vendored
Normal 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
1
.nvmrc
Normal file
|
|
@ -0,0 +1 @@
|
|||
v14.17.3
|
||||
21
LICENSE.md
Normal file
21
LICENSE.md
Normal 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
76
README.md
Normal 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
9
app/(auth)/layout.tsx
Normal 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
37
app/(auth)/login/page.tsx
Normal 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't have an account? Sign Up
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
41
app/(auth)/register/page.tsx
Normal file
41
app/(auth)/register/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
54
app/(dashboard)/dashboard/layout.tsx
Normal file
54
app/(dashboard)/dashboard/layout.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
21
app/(dashboard)/dashboard/loading.tsx
Normal file
21
app/(dashboard)/dashboard/loading.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
60
app/(dashboard)/dashboard/page.tsx
Normal file
60
app/(dashboard)/dashboard/page.tsx
Normal 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'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>
|
||||
)
|
||||
}
|
||||
18
app/(dashboard)/dashboard/settings/loading.tsx
Normal file
18
app/(dashboard)/dashboard/settings/loading.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
40
app/(dashboard)/dashboard/settings/page.tsx
Normal file
40
app/(dashboard)/dashboard/settings/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
21
app/(editor)/editor/[postId]/not-found.tsx
Normal file
21
app/(editor)/editor/[postId]/not-found.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
38
app/(editor)/editor/[postId]/page.tsx
Normal file
38
app/(editor)/editor/[postId]/page.tsx
Normal 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,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
15
app/(editor)/editor/layout.tsx
Normal file
15
app/(editor)/editor/layout.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
23
app/(marketing)/layout.tsx
Normal file
23
app/(marketing)/layout.tsx
Normal 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
33
app/(marketing)/page.tsx
Normal 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
9
app/head.tsx
Normal 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
18
app/layout.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
10
components/dashboard-branding.tsx
Normal file
10
components/dashboard-branding.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
23
components/dashboard-header.tsx
Normal file
23
components/dashboard-header.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
65
components/dashboard-nav.tsx
Normal file
65
components/dashboard-nav.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
16
components/dashboard-shell.tsx
Normal file
16
components/dashboard-shell.tsx
Normal 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
164
components/editor.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
79
components/empty-placeholder.tsx
Normal file
79
components/empty-placeholder.tsx
Normal 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
34
components/icons.tsx
Normal 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,
|
||||
}
|
||||
69
components/post-create-button.tsx
Normal file
69
components/post-create-button.tsx
Normal 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
42
components/post-item.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
98
components/post-operations.tsx
Normal file
98
components/post-operations.tsx
Normal 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
112
components/ui/alert.tsx
Normal 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
40
components/ui/avatar.tsx
Normal 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
66
components/ui/card.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
67
components/ui/dropdown.tsx
Normal file
67
components/ui/dropdown.tsx
Normal 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
89
components/ui/toast.tsx
Normal 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 }
|
||||
)
|
||||
}
|
||||
77
components/user-account-nav.tsx
Normal file
77
components/user-account-nav.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
120
components/user-auth-form.tsx
Normal file
120
components/user-auth-form.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
21
components/user-avatar.tsx
Normal file
21
components/user-avatar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
116
components/user-name-form.tsx
Normal file
116
components/user-name-form.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
14
lib/api-middlewares/with-authentication.ts
Normal file
14
lib/api-middlewares/with-authentication.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
11
lib/api-middlewares/with-methods.ts
Normal file
11
lib/api-middlewares/with-methods.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
37
lib/api-middlewares/with-post.ts
Normal file
37
lib/api-middlewares/with-post.ts
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
30
lib/api-middlewares/with-user.ts
Normal file
30
lib/api-middlewares/with-user.ts
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
24
lib/api-middlewares/with-validation.ts
Normal file
24
lib/api-middlewares/with-validation.ts
Normal 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
18
lib/db.ts
Normal 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
7
lib/models.ts
Normal 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
15
lib/session.ts
Normal 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
15
lib/utils.ts
Normal 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
5
lib/validations/auth.ts
Normal 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
8
lib/validations/post.ts
Normal 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
5
lib/validations/user.ts
Normal 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
22
middleware.ts
Normal 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
14
next.config.js
Normal 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
68
package.json
Normal 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
0
pages/.gitkeep
Normal file
77
pages/api/auth/[...nextauth].ts
Normal file
77
pages/api/auth/[...nextauth].ts
Normal 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)
|
||||
62
pages/api/posts/[postId].ts
Normal file
62
pages/api/posts/[postId].ts
Normal 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
64
pages/api/posts/index.ts
Normal 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))
|
||||
43
pages/api/users/[userId].ts
Normal file
43
pages/api/users/[userId].ts
Normal 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
6
postcss.config.js
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
77
prisma/migrations/20221021182747_init/migration.sql
Normal file
77
prisma/migrations/20221021182747_init/migration.sql
Normal 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;
|
||||
3
prisma/migrations/migration_lock.toml
Normal file
3
prisma/migrations/migration_lock.toml
Normal 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
84
prisma/schema.prisma
Normal 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
BIN
public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
BIN
public/images/nextjs-icon-dark-background.png
Normal file
BIN
public/images/nextjs-icon-dark-background.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 36 KiB |
4
public/vercel.svg
Normal file
4
public/vercel.svg
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<svg width="283" height="64" viewBox="0 0 283 64" fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M141.04 16c-11.04 0-19 7.2-19 18s8.96 18 20 18c6.67 0 12.55-2.64 16.19-7.09l-7.65-4.42c-2.02 2.21-5.09 3.5-8.54 3.5-4.79 0-8.86-2.5-10.37-6.5h28.02c.22-1.12.35-2.28.35-3.5 0-10.79-7.96-17.99-19-17.99zm-9.46 14.5c1.25-3.99 4.67-6.5 9.45-6.5 4.79 0 8.21 2.51 9.45 6.5h-18.9zM248.72 16c-11.04 0-19 7.2-19 18s8.96 18 20 18c6.67 0 12.55-2.64 16.19-7.09l-7.65-4.42c-2.02 2.21-5.09 3.5-8.54 3.5-4.79 0-8.86-2.5-10.37-6.5h28.02c.22-1.12.35-2.28.35-3.5 0-10.79-7.96-17.99-19-17.99zm-9.45 14.5c1.25-3.99 4.67-6.5 9.45-6.5 4.79 0 8.21 2.51 9.45 6.5h-18.9zM200.24 34c0 6 3.92 10 10 10 4.12 0 7.21-1.87 8.8-4.92l7.68 4.43c-3.18 5.3-9.14 8.49-16.48 8.49-11.05 0-19-7.2-19-18s7.96-18 19-18c7.34 0 13.29 3.19 16.48 8.49l-7.68 4.43c-1.59-3.05-4.68-4.92-8.8-4.92-6.07 0-10 4-10 10zm82.48-29v46h-9V5h9zM36.95 0L73.9 64H0L36.95 0zm92.38 5l-27.71 48L73.91 5H84.3l17.32 30 17.32-30h10.39zm58.91 12v9.69c-1-.29-2.06-.49-3.2-.49-5.81 0-10 4-10 10V51h-9V17h9v9.2c0-5.08 5.91-9.2 13.2-9.2z" fill="#000"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
3
styles/globals.css
Normal file
3
styles/globals.css
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
27
tailwind.config.js
Normal file
27
tailwind.config.js
Normal 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
48
tsconfig.json
Normal 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
9
types/next-auth.d.ts
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { User } from "next-auth"
|
||||
|
||||
declare module "next-auth" {
|
||||
interface Session {
|
||||
user: User & {
|
||||
id: string
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue