├── .prettierignore
├── types.d.ts
├── src
├── components
│ ├── ui
│ │ ├── header
│ │ │ ├── index.ts
│ │ │ ├── user-button.tsx
│ │ │ ├── login-button.tsx
│ │ │ └── header.tsx
│ │ ├── post-author-skeleton.tsx
│ │ ├── comments
│ │ │ ├── comment-author-skeleton.tsx
│ │ │ ├── comment-section-skeleton.tsx
│ │ │ ├── comments-list.tsx
│ │ │ ├── comment-text.tsx
│ │ │ ├── comment-skeleton.tsx
│ │ │ ├── comment-author.tsx
│ │ │ ├── comment.tsx
│ │ │ ├── comment-section.tsx
│ │ │ └── comment-options.tsx
│ │ ├── icons.tsx
│ │ ├── skeleton.tsx
│ │ ├── label.tsx
│ │ ├── textarea.tsx
│ │ ├── input.tsx
│ │ ├── post-card.tsx
│ │ ├── delete-post-button.tsx
│ │ ├── edit-post-button.tsx
│ │ ├── post-author.tsx
│ │ ├── theme-switch.tsx
│ │ ├── confirm-dialog.tsx
│ │ ├── card.tsx
│ │ ├── text.tsx
│ │ ├── button.tsx
│ │ ├── dialog.tsx
│ │ ├── form.tsx
│ │ └── dropdown-menu.tsx
│ └── forms
│ │ ├── sign-in-form.tsx
│ │ ├── sign-up-form.tsx
│ │ ├── create-post-form.tsx
│ │ ├── edit-comment-form.tsx
│ │ ├── edit-post-form.tsx
│ │ └── create-comment-form.tsx
├── app
│ ├── favicon.ico
│ ├── globals.css
│ ├── opengraph-image.jpg
│ ├── providers.tsx
│ ├── posts
│ │ └── [slug]
│ │ │ ├── loading.tsx
│ │ │ └── page.tsx
│ ├── new
│ │ └── page.tsx
│ ├── (auth)
│ │ ├── sign-up
│ │ │ └── [[...sign-up]]
│ │ │ │ └── page.tsx
│ │ └── sign-in
│ │ │ └── [[...sign-in]]
│ │ │ └── page.tsx
│ ├── _actions
│ │ ├── comment.actions.ts
│ │ └── post.actions.ts
│ ├── page.tsx
│ ├── layout.tsx
│ └── api
│ │ └── webhooks
│ │ └── user
│ │ └── route.ts
├── lib
│ ├── utils.ts
│ ├── hooks
│ │ └── useClerkAppearance.ts
│ └── validations
│ │ ├── comment.schema.ts
│ │ └── post.schema.ts
├── types
│ └── clerk.d.ts
├── config
│ └── site.ts
├── db
│ ├── index.ts
│ └── schema.ts
└── middleware.ts
├── public
└── images
│ ├── logo.png
│ └── avatar.webp
├── postcss.config.js
├── drizzle.config.ts
├── .env.example
├── components.json
├── next.config.js
├── .gitignore
├── .prettierrc
├── tsconfig.json
├── tailwind.config.js
├── .eslintrc.json
├── package.json
└── README.md
/.prettierignore:
--------------------------------------------------------------------------------
1 | build
2 | .next
3 | node_modules
--------------------------------------------------------------------------------
/types.d.ts:
--------------------------------------------------------------------------------
1 | declare module "tailwindcss-animate";
2 |
--------------------------------------------------------------------------------
/src/components/ui/header/index.ts:
--------------------------------------------------------------------------------
1 | export { Header } from "./header";
2 |
--------------------------------------------------------------------------------
/src/app/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/leojuriolli7/ribbit/HEAD/src/app/favicon.ico
--------------------------------------------------------------------------------
/src/app/globals.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
--------------------------------------------------------------------------------
/public/images/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/leojuriolli7/ribbit/HEAD/public/images/logo.png
--------------------------------------------------------------------------------
/public/images/avatar.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/leojuriolli7/ribbit/HEAD/public/images/avatar.webp
--------------------------------------------------------------------------------
/src/app/opengraph-image.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/leojuriolli7/ribbit/HEAD/src/app/opengraph-image.jpg
--------------------------------------------------------------------------------
/postcss.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | }
7 |
--------------------------------------------------------------------------------
/src/lib/utils.ts:
--------------------------------------------------------------------------------
1 | import { type ClassValue, clsx } from "clsx"
2 | import { twMerge } from "tailwind-merge"
3 |
4 | export function cn(...inputs: ClassValue[]) {
5 | return twMerge(clsx(inputs))
6 | }
7 |
--------------------------------------------------------------------------------
/src/types/clerk.d.ts:
--------------------------------------------------------------------------------
1 | import _Types from "@clerk/types";
2 |
3 | declare global {
4 | interface UserPublicMetadata {
5 | /**
6 | * This is the Clerk user id inside
7 | * the project's MySQL database.
8 | */
9 | databaseId: number;
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/src/config/site.ts:
--------------------------------------------------------------------------------
1 | export const siteConfig = {
2 | name: "Ribbit",
3 | description: "Next.js 13 demo",
4 | keywords: [
5 | "Next.js",
6 | "React",
7 | "Tailwind CSS",
8 | "Server Components",
9 | "Server Actions",
10 | "Ribbit",
11 | ],
12 | url: "https://ribbit-zeta.vercel.app",
13 | };
14 |
--------------------------------------------------------------------------------
/drizzle.config.ts:
--------------------------------------------------------------------------------
1 | import type { Config } from "drizzle-kit";
2 | import * as dotenv from "dotenv";
3 |
4 | dotenv.config();
5 |
6 | export default {
7 | schema: "./src/db/schema.ts",
8 | driver: "mysql2",
9 | out: "./drizzle",
10 | dbCredentials: {
11 | connectionString: process.env.DATABASE_URL || "",
12 | },
13 | } satisfies Config;
14 |
--------------------------------------------------------------------------------
/src/app/providers.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { ThemeProvider } from "next-themes";
4 |
5 | type Props = {
6 | children: React.ReactNode;
7 | };
8 |
9 | export function Providers({ children }: Props) {
10 | return (
11 |
12 | {children}
13 |
14 | );
15 | }
16 |
--------------------------------------------------------------------------------
/src/components/ui/post-author-skeleton.tsx:
--------------------------------------------------------------------------------
1 | "use server";
2 |
3 | import { Skeleton } from "./skeleton";
4 |
5 | export const PostAuthorSkeleton = () => {
6 | return (
7 |
8 |
9 |
10 |
11 | );
12 | };
13 |
--------------------------------------------------------------------------------
/src/components/ui/comments/comment-author-skeleton.tsx:
--------------------------------------------------------------------------------
1 | "use server";
2 |
3 | import { Skeleton } from "../skeleton";
4 |
5 | export const CommentAuthorSkeleton = () => {
6 | return (
7 |
8 |
9 |
10 |
11 | );
12 | };
13 |
--------------------------------------------------------------------------------
/.env.example:
--------------------------------------------------------------------------------
1 | # Planetscale DB credentials
2 | DATABASE_HOST=
3 | DATABASE_USERNAME=
4 | DATABASE_PASSWORD=
5 | DATABASE_URL=
6 |
7 | # Clerk auth credentials
8 | NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=
9 | CLERK_SECRET_KEY=
10 |
11 | NEXT_PUBLIC_CLERK_SIGN_IN_URL=/sign-in
12 | NEXT_PUBLIC_CLERK_SIGN_UP_URL=/sign-up
13 | NEXT_PUBLIC_CLERK_AFTER_SIGN_IN_URL=/
14 | NEXT_PUBLIC_CLERK_AFTER_SIGN_UP_URL=/
--------------------------------------------------------------------------------
/src/app/posts/[slug]/loading.tsx:
--------------------------------------------------------------------------------
1 | import { Skeleton } from "@/components/ui/skeleton";
2 |
3 | export default function LoadingPost() {
4 | return (
5 | <>
6 |
7 |
8 |
9 |
10 |
11 | >
12 | );
13 | }
14 |
--------------------------------------------------------------------------------
/components.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://ui.shadcn.com/schema.json",
3 | "style": "new-york",
4 | "rsc": true,
5 | "tsx": true,
6 | "tailwind": {
7 | "config": "tailwind.config.js",
8 | "css": "./src/app/globals.css",
9 | "baseColor": "zinc",
10 | "cssVariables": false
11 | },
12 | "aliases": {
13 | "components": "@/components",
14 | "utils": "@/lib/utils"
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/src/components/ui/icons.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Loader2,
3 | Plus,
4 | type Icon as LucideIcon,
5 | LogIn,
6 | PencilIcon,
7 | X as CloseIcon,
8 | Trash2Icon,
9 | } from "lucide-react";
10 |
11 | export type Icon = LucideIcon;
12 |
13 | export const Icons = {
14 | spinner: Loader2,
15 | plus: Plus,
16 | login: LogIn,
17 | edit: PencilIcon,
18 | close: CloseIcon,
19 | delete: Trash2Icon,
20 | };
21 |
--------------------------------------------------------------------------------
/src/components/ui/skeleton.tsx:
--------------------------------------------------------------------------------
1 | import { cn } from "@/lib/utils";
2 |
3 | function Skeleton({
4 | className,
5 | ...props
6 | }: React.HTMLAttributes) {
7 | return (
8 |
15 | );
16 | }
17 |
18 | export { Skeleton };
19 |
--------------------------------------------------------------------------------
/src/db/index.ts:
--------------------------------------------------------------------------------
1 | import { drizzle } from "drizzle-orm/planetscale-serverless";
2 | import { connect } from "@planetscale/database";
3 |
4 | import * as schema from "./schema";
5 |
6 | const connection = connect({
7 | host: process.env["DATABASE_HOST"],
8 | username: process.env["DATABASE_USERNAME"],
9 | password: process.env["DATABASE_PASSWORD"],
10 | });
11 |
12 | export const db = drizzle(connection, { schema });
13 |
--------------------------------------------------------------------------------
/next.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('next').NextConfig} */
2 | const nextConfig = {
3 | experimental: {
4 | serverActions: true,
5 | },
6 | images: {
7 | remotePatterns: [
8 | {
9 | protocol: "https",
10 | hostname: "img.clerk.com",
11 | },
12 | {
13 | protocol: "https",
14 | hostname: "ribbit-zeta.vercel.app",
15 | },
16 | ],
17 | },
18 | };
19 |
20 | module.exports = nextConfig;
21 |
--------------------------------------------------------------------------------
/src/middleware.ts:
--------------------------------------------------------------------------------
1 | import { authMiddleware } from "@clerk/nextjs/server";
2 |
3 | export default authMiddleware({
4 | // Public routes are routes that don't require authentication
5 | publicRoutes: [
6 | "/",
7 | "/posts(.*)",
8 | "/signin(.*)",
9 | "/signup(.*)",
10 | "/sso-callback(.*)",
11 | "/api/webhooks/user",
12 | ],
13 | });
14 |
15 | export const config = {
16 | matcher: ["/((?!.*\\..*|_next).*)", "/", "/(api)(.*)"],
17 | };
18 |
--------------------------------------------------------------------------------
/src/app/new/page.tsx:
--------------------------------------------------------------------------------
1 | import Text from "@/components/ui/text";
2 | import { CreatePostForm } from "@/components/forms/create-post-form";
3 | import type { Metadata } from "next";
4 |
5 | export const metadata: Metadata = {
6 | title: "Create a post",
7 | };
8 |
9 | export default function Home() {
10 | return (
11 |
12 |
13 | Create a post
14 |
15 |
16 |
17 |
18 | );
19 | }
20 |
--------------------------------------------------------------------------------
/src/components/ui/comments/comment-section-skeleton.tsx:
--------------------------------------------------------------------------------
1 | "use server";
2 |
3 | import { CommentSkeleton } from "./comment-skeleton";
4 |
5 | const COMMENTS = Array.from({ length: 4 });
6 |
7 | export const CommentSectionSkeleton = () => {
8 | return (
9 |
10 |
11 | {COMMENTS?.map((_, i) => {
12 | return ;
13 | })}
14 |
15 |
16 | );
17 | };
18 |
--------------------------------------------------------------------------------
/src/components/ui/comments/comments-list.tsx:
--------------------------------------------------------------------------------
1 | "use server";
2 |
3 | import type { CommentWithChildren } from "./comment-section";
4 | import { Comment } from "./comment";
5 |
6 | export const CommentsList = ({
7 | comments,
8 | }: {
9 | comments: CommentWithChildren[];
10 | }) => {
11 | return (
12 |
13 | {comments?.map((comment) => {
14 | return ;
15 | })}
16 |
17 | );
18 | };
19 |
--------------------------------------------------------------------------------
/src/lib/hooks/useClerkAppearance.ts:
--------------------------------------------------------------------------------
1 | import { dark } from "@clerk/themes";
2 | import { useTheme } from "next-themes";
3 |
4 | /** Get the clerk appearance props (Light mode, dark mode & primary color) */
5 | export default function useClerkAppearance() {
6 | const { resolvedTheme: theme } = useTheme();
7 | const clerkWidgetTheme = theme === "dark" ? dark : undefined;
8 |
9 | return {
10 | baseTheme: clerkWidgetTheme,
11 | variables: {
12 | colorPrimary: "#22c55e",
13 | },
14 | };
15 | }
16 |
--------------------------------------------------------------------------------
/src/components/ui/comments/comment-text.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useSearchParams } from "next/navigation";
4 | import Text from "../text";
5 |
6 | type Props = {
7 | text: string;
8 | id: number;
9 | };
10 |
11 | export const CommentText = ({ id, text }: Props) => {
12 | const params = useSearchParams();
13 |
14 | const currentlyEditing = params.get("editingComment");
15 | const hideText = currentlyEditing === String(id);
16 |
17 | return !hideText && {text};
18 | };
19 |
--------------------------------------------------------------------------------
/src/app/(auth)/sign-up/[[...sign-up]]/page.tsx:
--------------------------------------------------------------------------------
1 | import { SignUpForm } from "@/components/forms/sign-up-form";
2 | import type { Metadata } from "next";
3 |
4 | export const runtime = "edge";
5 |
6 | export const metadata: Metadata = {
7 | title: "Sign up",
8 | };
9 | export default function SignUpPage({
10 | searchParams,
11 | }: {
12 | searchParams: {
13 | redirectUrl?: string;
14 | };
15 | }) {
16 | const { redirectUrl } = searchParams || {};
17 |
18 | return ;
19 | }
20 |
--------------------------------------------------------------------------------
/src/app/(auth)/sign-in/[[...sign-in]]/page.tsx:
--------------------------------------------------------------------------------
1 | import { SignInForm } from "@/components/forms/sign-in-form";
2 | import type { Metadata } from "next";
3 |
4 | export const runtime = "edge";
5 |
6 | export const metadata: Metadata = {
7 | title: "Sign in",
8 | };
9 |
10 | export default function SignInPage({
11 | searchParams,
12 | }: {
13 | searchParams: {
14 | redirectUrl?: string;
15 | };
16 | }) {
17 | const { redirectUrl } = searchParams || {};
18 |
19 | return ;
20 | }
21 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 |
8 | # testing
9 | /coverage
10 |
11 | # next.js
12 | /.next/
13 | /out/
14 |
15 | # production
16 | /build
17 |
18 | # misc
19 | .DS_Store
20 | *.pem
21 |
22 | # debug
23 | npm-debug.log*
24 | yarn-debug.log*
25 | yarn-error.log*
26 |
27 | # local env files
28 | .env*.local
29 | .env
30 |
31 | # vercel
32 | .vercel
33 |
34 | # typescript
35 | *.tsbuildinfo
36 | next-env.d.ts
37 |
--------------------------------------------------------------------------------
/src/components/ui/header/user-button.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { UserButton as ClerkUserButton } from "@clerk/nextjs";
4 | import useClerkAppearance from "@/lib/hooks/useClerkAppearance";
5 | import { usePathname } from "next/navigation";
6 |
7 | export const UserButton = () => {
8 | const appearance = useClerkAppearance();
9 | const pathname = usePathname();
10 |
11 | return (
12 |
19 | );
20 | };
21 |
--------------------------------------------------------------------------------
/src/lib/validations/comment.schema.ts:
--------------------------------------------------------------------------------
1 | import * as z from "zod";
2 |
3 | export const createCommentSchema = z.object({
4 | text: z.string().trim().min(3, "Required"),
5 | userId: z.number(),
6 | postId: z.number(),
7 | slug: z.string().nonempty(),
8 | parentId: z.number().optional(),
9 | });
10 |
11 | export type CreateCommentInput = z.infer;
12 |
13 | export const editCommentSchema = z.object({
14 | text: z.string().trim().min(3, "Required"),
15 | commentId: z.number(),
16 | slug: z.string().nonempty(),
17 | });
18 |
19 | export type EditCommentInput = z.infer;
20 |
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "arrowParens": "always",
3 | "bracketSpacing": true,
4 | "endOfLine": "lf",
5 | "htmlWhitespaceSensitivity": "css",
6 | "insertPragma": false,
7 | "singleAttributePerLine": false,
8 | "bracketSameLine": false,
9 | "jsxBracketSameLine": false,
10 | "jsxSingleQuote": false,
11 | "printWidth": 80,
12 | "proseWrap": "preserve",
13 | "quoteProps": "as-needed",
14 | "requirePragma": false,
15 | "semi": true,
16 | "singleQuote": false,
17 | "tabWidth": 2,
18 | "trailingComma": "es5",
19 | "useTabs": false,
20 | "embeddedLanguageFormatting": "auto",
21 | "vueIndentScriptAndStyle": false
22 | }
23 |
--------------------------------------------------------------------------------
/src/lib/validations/post.schema.ts:
--------------------------------------------------------------------------------
1 | import * as z from "zod";
2 |
3 | export const createPostSchema = z.object({
4 | title: z.string().trim().min(1, "Required"),
5 | description: z.string().trim().min(3, "Minimum of 3 characters"),
6 | userId: z.number(),
7 | });
8 |
9 | export type CreatePostInput = z.infer;
10 |
11 | export const editPostSchema = z.object({
12 | title: z.string().trim().min(1, "Required"),
13 | description: z.string().trim().min(3, "Minimum of 3 characters"),
14 | slug: z.string(),
15 | userId: z.number(),
16 | });
17 |
18 | export type EditPostInput = z.infer;
19 |
--------------------------------------------------------------------------------
/src/components/ui/comments/comment-skeleton.tsx:
--------------------------------------------------------------------------------
1 | "use server";
2 |
3 | import { Skeleton } from "../skeleton";
4 |
5 | export const CommentSkeleton = () => {
6 | return (
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 | );
20 | };
21 |
--------------------------------------------------------------------------------
/src/components/ui/header/login-button.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import Link from "next/link";
4 | import { usePathname } from "next/navigation";
5 | import { Icons } from "../icons";
6 | import { Button } from "../button";
7 |
8 | export const LoginButton = () => {
9 | const pathname = usePathname();
10 |
11 | return (
12 |
13 |
20 |
21 | );
22 | };
23 |
--------------------------------------------------------------------------------
/src/components/forms/sign-in-form.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import useClerkAppearance from "@/lib/hooks/useClerkAppearance";
4 | import { SignIn } from "@clerk/nextjs";
5 |
6 | export const SignInForm = ({ redirectUrl }: { redirectUrl?: string }) => {
7 | const appearance = useClerkAppearance();
8 |
9 | return (
10 |
11 |
22 |
23 | );
24 | };
25 |
--------------------------------------------------------------------------------
/src/components/forms/sign-up-form.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import useClerkAppearance from "@/lib/hooks/useClerkAppearance";
4 | import { SignUp } from "@clerk/nextjs";
5 |
6 | export const SignUpForm = ({ redirectUrl }: { redirectUrl?: string }) => {
7 | const appearance = useClerkAppearance();
8 |
9 | return (
10 |
11 |
22 |
23 | );
24 | };
25 |
--------------------------------------------------------------------------------
/src/components/ui/label.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as LabelPrimitive from "@radix-ui/react-label"
5 | import { cva, type VariantProps } from "class-variance-authority"
6 |
7 | import { cn } from "@/lib/utils"
8 |
9 | const labelVariants = cva(
10 | "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
11 | )
12 |
13 | const Label = React.forwardRef<
14 | React.ElementRef,
15 | React.ComponentPropsWithoutRef &
16 | VariantProps
17 | >(({ className, ...props }, ref) => (
18 |
23 | ))
24 | Label.displayName = LabelPrimitive.Root.displayName
25 |
26 | export { Label }
27 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "baseUrl": ".",
4 | "target": "es2017",
5 | "lib": ["dom", "dom.iterable", "esnext"],
6 | "allowJs": true,
7 | "skipLibCheck": true,
8 | "checkJs": true,
9 | "strict": true,
10 | "forceConsistentCasingInFileNames": true,
11 | "noEmit": true,
12 | "esModuleInterop": true,
13 | "module": "esnext",
14 | "moduleResolution": "node",
15 | "resolveJsonModule": true,
16 | "isolatedModules": true,
17 | "jsx": "preserve",
18 | "incremental": true,
19 | "plugins": [
20 | {
21 | "name": "next"
22 | }
23 | ],
24 | "paths": {
25 | "@/*": ["./src/*"]
26 | }
27 | },
28 | "include": [
29 | "next-env.d.ts",
30 | "**/*.ts",
31 | "**/*.tsx",
32 | ".next/types/**/*.ts",
33 | "postcss.config.js",
34 | "tailwind.config.js"
35 | ],
36 | "exclude": ["node_modules"]
37 | }
38 |
--------------------------------------------------------------------------------
/src/components/ui/textarea.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 |
3 | import { cn } from "@/lib/utils";
4 |
5 | export type TextareaProps = React.TextareaHTMLAttributes;
6 |
7 | const Textarea = React.forwardRef(
8 | ({ className, ...props }, ref) => {
9 | return (
10 |
18 | );
19 | }
20 | );
21 | Textarea.displayName = "Textarea";
22 |
23 | export { Textarea };
24 |
--------------------------------------------------------------------------------
/src/components/ui/input.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 |
3 | import { cn } from "@/lib/utils";
4 |
5 | export type InputProps = React.InputHTMLAttributes;
6 |
7 | const Input = React.forwardRef(
8 | ({ className, type, ...props }, ref) => {
9 | return (
10 |
19 | );
20 | }
21 | );
22 | Input.displayName = "Input";
23 |
24 | export { Input };
25 |
--------------------------------------------------------------------------------
/tailwind.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('tailwindcss').Config} */
2 | module.exports = {
3 | darkMode: ["class"],
4 | content: [
5 | "./pages/**/*.{ts,tsx}",
6 | "./components/**/*.{ts,tsx}",
7 | "./app/**/*.{ts,tsx}",
8 | "./src/**/*.{ts,tsx}",
9 | ],
10 | theme: {
11 | container: {
12 | center: true,
13 | padding: "2rem",
14 | screens: {
15 | "2xl": "1400px",
16 | },
17 | },
18 | extend: {
19 | keyframes: {
20 | "accordion-down": {
21 | from: { height: 0 },
22 | to: { height: "var(--radix-accordion-content-height)" },
23 | },
24 | "accordion-up": {
25 | from: { height: "var(--radix-accordion-content-height)" },
26 | to: { height: 0 },
27 | },
28 | },
29 | animation: {
30 | "accordion-down": "accordion-down 0.2s ease-out",
31 | "accordion-up": "accordion-up 0.2s ease-out",
32 | },
33 | },
34 | },
35 | plugins: [require("tailwindcss-animate")],
36 | };
37 |
--------------------------------------------------------------------------------
/src/components/ui/post-card.tsx:
--------------------------------------------------------------------------------
1 | import { Card, CardContent, CardHeader, CardTitle } from "./card";
2 | import Text from "./text";
3 | import Link from "next/link";
4 | import { cn } from "@/lib/utils";
5 |
6 | type PostCardProps = {
7 | title: string;
8 | description: string | null;
9 | slug: string;
10 | className?: string;
11 | };
12 |
13 | export const PostCard: React.FC = ({
14 | title,
15 | description,
16 | slug,
17 | className,
18 | }) => {
19 | return (
20 |
21 |
27 |
28 | {title}
29 |
30 |
31 |
32 | {description}
33 |
34 |
35 |
36 |
37 | );
38 | };
39 |
--------------------------------------------------------------------------------
/src/components/ui/delete-post-button.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { Button } from "./button";
4 | import { Icons } from "./icons";
5 | import { ConfirmDialog } from "./confirm-dialog";
6 | import { useTransition } from "react";
7 | import { deletePostAction } from "@/app/_actions/post.actions";
8 |
9 | export const DeletePostButton = ({ slug }: { slug: string }) => {
10 | const [deleting, startTransition] = useTransition();
11 |
12 | const onClickDelete = () => startTransition(() => deletePostAction(slug));
13 |
14 | return (
15 |
28 |
29 |
30 | }
31 | />
32 | );
33 | };
34 |
--------------------------------------------------------------------------------
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "overrides": [
3 | {
4 | "extends": [
5 | "plugin:@typescript-eslint/recommended-requiring-type-checking"
6 | ],
7 | "files": ["*.ts", "*.tsx"],
8 | "parserOptions": {
9 | "project": "./tsconfig.json"
10 | }
11 | }
12 | ],
13 | "parser": "@typescript-eslint/parser",
14 | "parserOptions": {
15 | "project": "./tsconfig.json"
16 | },
17 | "plugins": ["@typescript-eslint"],
18 | "extends": ["next/core-web-vitals", "plugin:@typescript-eslint/recommended"],
19 | "rules": {
20 | "@typescript-eslint/no-misused-promises": [
21 | 2,
22 | {
23 | "checksVoidReturn": {
24 | "attributes": false
25 | }
26 | }
27 | ],
28 | "@typescript-eslint/consistent-type-imports": [
29 | "warn",
30 | {
31 | "prefer": "type-imports",
32 | "fixStyle": "inline-type-imports"
33 | }
34 | ],
35 | "@typescript-eslint/no-unused-vars": [
36 | "warn",
37 | { "argsIgnorePattern": "^_", "varsIgnorePattern": "^_" }
38 | ]
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/src/components/ui/edit-post-button.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { Button } from "./button";
4 | import Link from "next/link";
5 | import { usePathname, useSearchParams } from "next/navigation";
6 | import type { ButtonHTMLAttributes } from "react";
7 | import { Icons } from "./icons";
8 |
9 | type Props = ButtonHTMLAttributes;
10 |
11 | const iconAttrs = {
12 | width: 21,
13 | height: 21,
14 | };
15 |
16 | export const EditPostButton = (props: Props) => {
17 | const pathname = usePathname();
18 | const searchParams = useSearchParams();
19 |
20 | const isEditing = searchParams.has("edit");
21 |
22 | const onClickHref = isEditing ? pathname : `${pathname}?edit=true`;
23 |
24 | return (
25 |
26 |
39 |
40 | );
41 | };
42 |
--------------------------------------------------------------------------------
/src/components/ui/comments/comment-author.tsx:
--------------------------------------------------------------------------------
1 | "use server";
2 | import { db } from "@/db";
3 | import { eq } from "drizzle-orm";
4 | import { users } from "@/db/schema";
5 | import Text from "../text";
6 | import Image from "next/image";
7 |
8 | export default async function CommentAuthor({
9 | authorId,
10 | createdAt,
11 | }: {
12 | authorId: number;
13 | createdAt: Date | null;
14 | }) {
15 | const author = await db.query.users.findFirst({
16 | where: eq(users.id, authorId),
17 | });
18 |
19 | if (!author) return null;
20 |
21 | const username = author?.firstName ?? author?.username ?? "Anon";
22 | const createdAtString = createdAt ? ` @ ${createdAt?.toLocaleString()}` : "";
23 |
24 | return (
25 |
26 |
33 |
34 | {username}{" "}
35 |
36 | {createdAtString}
37 |
38 |
39 |
40 | );
41 | }
42 |
--------------------------------------------------------------------------------
/src/app/_actions/comment.actions.ts:
--------------------------------------------------------------------------------
1 | "use server";
2 |
3 | import { db } from "@/db";
4 | import { comments } from "@/db/schema";
5 | import type {
6 | CreateCommentInput,
7 | EditCommentInput,
8 | } from "@/lib/validations/comment.schema";
9 | import { eq } from "drizzle-orm";
10 | import { revalidatePath } from "next/cache";
11 |
12 | export const createCommentAction = async ({
13 | postId,
14 | text,
15 | userId,
16 | slug,
17 | /** Pass this to reply to a comment. */
18 | parentId,
19 | }: CreateCommentInput) => {
20 | await db.insert(comments).values({
21 | userId,
22 | text,
23 | postId,
24 | ...(typeof parentId === "number" && { parentId }),
25 | });
26 |
27 | revalidatePath(`/posts/${slug}`);
28 | };
29 |
30 | export const editCommentAction = async ({
31 | commentId,
32 | text,
33 | slug,
34 | }: EditCommentInput) => {
35 | await db
36 | .update(comments)
37 | .set({
38 | text,
39 | })
40 | .where(eq(comments.id, commentId));
41 |
42 | revalidatePath(`/posts/${slug}`);
43 | };
44 |
45 | export const deleteCommentAction = async ({
46 | idToDelete,
47 | postSlug,
48 | }: {
49 | idToDelete: number;
50 | postSlug: string;
51 | }) => {
52 | await db.delete(comments).where(eq(comments.id, idToDelete));
53 |
54 | revalidatePath(`/posts/${postSlug}`);
55 | };
56 |
--------------------------------------------------------------------------------
/src/app/page.tsx:
--------------------------------------------------------------------------------
1 | import Text from "@/components/ui/text";
2 | import { db } from "@/db";
3 | import { posts } from "@/db/schema";
4 | import { PostCard } from "@/components/ui/post-card";
5 | import { desc } from "drizzle-orm";
6 | import type { Metadata } from "next";
7 | import { siteConfig } from "@/config/site";
8 |
9 | export const runtime = "edge";
10 |
11 | export const metadata: Metadata = {
12 | title: `${siteConfig.name} | The forefront of amphibious web browsing`,
13 | };
14 |
15 | async function getPosts() {
16 | return await db.query.posts.findMany({
17 | orderBy: [desc(posts.createdAt)],
18 | });
19 | }
20 |
21 | export default async function Home() {
22 | const posts = await getPosts();
23 |
24 | return (
25 |
26 |
27 | Past posts
28 |
29 |
30 |
31 |
32 | {posts?.map((post) => (
33 |
38 | ))}
39 |
40 |
41 | {!posts?.length &&
No posts found.}
42 |
43 |
44 | );
45 | }
46 |
--------------------------------------------------------------------------------
/src/components/ui/post-author.tsx:
--------------------------------------------------------------------------------
1 | "use server";
2 |
3 | import Text from "./text";
4 | import Image from "next/image";
5 | import { db } from "@/db";
6 | import { eq } from "drizzle-orm";
7 | import { users } from "@/db/schema";
8 |
9 | export default async function PostAuthor({
10 | userId,
11 | createdAt,
12 | }: {
13 | userId: number;
14 | createdAt: Date | null;
15 | }) {
16 | const author = await db.query.users.findFirst({
17 | where: eq(users.id, userId),
18 | });
19 |
20 | if (!author) return null;
21 |
22 | const username = author?.firstName ?? author?.username ?? "Anon";
23 | const createdAtString = createdAt ? ` @ ${createdAt?.toLocaleString()}` : "";
24 |
25 | return (
26 |
27 |
34 |
35 |
40 | {username}
41 |
42 |
43 | {createdAtString}
44 |
45 |
46 |
47 | );
48 | }
49 |
--------------------------------------------------------------------------------
/src/components/ui/theme-switch.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as React from "react";
4 | import { MoonIcon, SunIcon } from "@radix-ui/react-icons";
5 | import { useTheme } from "next-themes";
6 |
7 | import { Button } from "@/components/ui/button";
8 | import {
9 | DropdownMenu,
10 | DropdownMenuContent,
11 | DropdownMenuItem,
12 | DropdownMenuTrigger,
13 | } from "@/components/ui/dropdown-menu";
14 |
15 | export function ThemeSwitch() {
16 | const { setTheme } = useTheme();
17 |
18 | return (
19 |
20 |
21 |
26 |
27 |
28 | setTheme("light")}>
29 | Light
30 |
31 | setTheme("dark")}>
32 | Dark
33 |
34 | setTheme("system")}>
35 | System
36 |
37 |
38 |
39 | );
40 | }
41 |
--------------------------------------------------------------------------------
/src/app/_actions/post.actions.ts:
--------------------------------------------------------------------------------
1 | "use server";
2 |
3 | import "server-only";
4 | import { db } from "@/db";
5 | import { posts } from "@/db/schema";
6 | import slugify from "slugify";
7 | import type {
8 | CreatePostInput,
9 | EditPostInput,
10 | } from "@/lib/validations/post.schema";
11 | import { revalidatePath } from "next/cache";
12 | import { redirect } from "next/navigation";
13 | import { eq } from "drizzle-orm";
14 |
15 | function getRandomHash() {
16 | const characters = "abcdefghijklmnopqrstuvwxyz0123456789";
17 | let result = "";
18 |
19 | for (let i = 0; i < 4; i++) {
20 | const randomIndex = Math.floor(Math.random() * characters.length);
21 | result += characters[randomIndex];
22 | }
23 | return result;
24 | }
25 |
26 | export const createPostAction = async (values: CreatePostInput) => {
27 | const randomHash = getRandomHash();
28 |
29 | const generatedSlug = slugify(values.title, {
30 | lower: true,
31 | });
32 |
33 | const slug = `${generatedSlug}-${randomHash}`;
34 |
35 | await db.insert(posts).values({ ...values, slug });
36 |
37 | revalidatePath("/");
38 | redirect(`/posts/${slug}`);
39 | };
40 |
41 | export const editPostAction = async (values: EditPostInput) => {
42 | await db
43 | .update(posts)
44 | .set({
45 | description: values.description,
46 | title: values.title,
47 | })
48 | .where(eq(posts.slug, values.slug));
49 |
50 | revalidatePath(`/posts/${values.slug}`);
51 | };
52 |
53 | export const deletePostAction = async (slug: string) => {
54 | await db.delete(posts).where(eq(posts.slug, slug));
55 |
56 | revalidatePath("/");
57 | redirect("/");
58 | };
59 |
--------------------------------------------------------------------------------
/src/components/ui/comments/comment.tsx:
--------------------------------------------------------------------------------
1 | "use server";
2 |
3 | import { cn } from "@/lib/utils";
4 | import type { CommentWithChildren } from "./comment-section";
5 | import { CommentsList } from "./comments-list";
6 | import { CommentOptions } from "./comment-options";
7 | import CommentAuthor from "./comment-author";
8 | import { Suspense } from "react";
9 | import { CommentAuthorSkeleton } from "./comment-author-skeleton";
10 | import { SignedIn } from "@clerk/nextjs";
11 | import { CommentText } from "./comment-text";
12 |
13 | export const Comment = (comment: CommentWithChildren) => {
14 | const hasChildren = !!comment?.children?.length;
15 | const hasParent = !!comment?.parentId;
16 |
17 | return (
18 |
26 |
27 | }>
28 |
32 |
33 |
34 |
35 |
36 |
42 |
43 |
44 |
45 | {hasChildren && }
46 |
47 | );
48 | };
49 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "ribbit",
3 | "version": "0.1.0",
4 | "private": true,
5 | "scripts": {
6 | "dev": "next dev",
7 | "build": "next build",
8 | "start": "next start",
9 | "lint": "next lint",
10 | "db:introspect": "drizzle-kit introspect:mysql",
11 | "db:push": "drizzle-kit push:mysql --config ./drizzle.config.ts"
12 | },
13 | "dependencies": {
14 | "@clerk/nextjs": "^4.21.14",
15 | "@clerk/themes": "^1.7.5",
16 | "@hookform/resolvers": "^3.1.1",
17 | "@planetscale/database": "^1.8.0",
18 | "@radix-ui/react-dialog": "^1.0.4",
19 | "@radix-ui/react-dropdown-menu": "^2.0.5",
20 | "@radix-ui/react-icons": "^1.3.0",
21 | "@radix-ui/react-label": "^2.0.2",
22 | "@radix-ui/react-slot": "^1.0.2",
23 | "@types/node": "20.4.0",
24 | "@types/react": "18.2.14",
25 | "@types/react-dom": "18.2.6",
26 | "@typescript-eslint/eslint-plugin": "^5.61.0",
27 | "@typescript-eslint/parser": "^5.61.0",
28 | "autoprefixer": "10.4.14",
29 | "class-variance-authority": "^0.6.1",
30 | "clsx": "^1.2.1",
31 | "drizzle-orm": "^0.27.0",
32 | "eslint": "8.44.0",
33 | "eslint-config-next": "13.4.9",
34 | "lucide-react": "^0.259.0",
35 | "next": "13.4.9",
36 | "next-themes": "^0.2.1",
37 | "postcss": "8.4.25",
38 | "react": "18.2.0",
39 | "react-dom": "18.2.0",
40 | "react-hook-form": "^7.45.1",
41 | "server-only": "^0.0.1",
42 | "sharp": "^0.32.1",
43 | "slugify": "^1.6.6",
44 | "svix": "^1.6.0",
45 | "tailwind-merge": "^1.13.2",
46 | "tailwindcss": "3.3.2",
47 | "tailwindcss-animate": "^1.0.6",
48 | "typescript": "5.1.6",
49 | "zod": "^3.21.4"
50 | },
51 | "devDependencies": {
52 | "dotenv": "^16.3.1",
53 | "drizzle-kit": "^0.19.3",
54 | "prettier": "3.0.0"
55 | }
56 | }
57 |
--------------------------------------------------------------------------------
/src/components/ui/header/header.tsx:
--------------------------------------------------------------------------------
1 | "use server";
2 |
3 | import Logo from "public/images/logo.png";
4 | import Image from "next/image";
5 | import Link from "next/link";
6 | import { Button } from "../button";
7 | import Text from "../text";
8 | import { Icons } from "../icons";
9 | import { ThemeSwitch } from "../theme-switch";
10 | import { SignedIn, SignedOut } from "@clerk/nextjs";
11 | import { UserButton } from "./user-button";
12 | import { LoginButton } from "./login-button";
13 |
14 | export const Header = () => {
15 | return (
16 |
55 | );
56 | };
57 |
--------------------------------------------------------------------------------
/src/components/ui/confirm-dialog.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import React from "react";
4 | import {
5 | Dialog,
6 | DialogContent,
7 | DialogDescription,
8 | DialogFooter,
9 | DialogHeader,
10 | DialogTitle,
11 | DialogTrigger,
12 | } from "./dialog";
13 | import { Button } from "./button";
14 |
15 | type Props = {
16 | trigger: React.ReactNode;
17 | title: string;
18 | description?: string;
19 | confirmButtonMessage: string;
20 | onClickDelete: () => void;
21 | loading: boolean;
22 | };
23 |
24 | export const ConfirmDialog = ({
25 | title,
26 | trigger,
27 | description = "This action is permanent and cannot be undone.",
28 | confirmButtonMessage,
29 | onClickDelete,
30 | loading,
31 | }: Props) => {
32 | return (
33 |
65 | );
66 | };
67 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | ## Ribbit | The forefront of amphibious web browsing
2 |
3 | Ribbit is a Reddit-inspired forum application built with [Next.js 13](https://nextjs.org/), [Drizzle ORM](https://orm.drizzle.team/) and [Clerk.](https://clerk.com/)
4 |
5 | ### Tech Stack
6 | - Next.js 13 [App router](https://nextjs.org/docs/app)
7 | - [Server components](https://nextjs.org/docs/getting-started/react-essentials#server-components) for direct secure access to backend resources and reduced client-side bundle sizes.
8 | - [Server Actions](https://nextjs.org/docs/app/building-your-application/data-fetching/server-actions).
9 | - React.js [Suspense](https://react.dev/reference/react/Suspense) and [Streaming data](https://nextjs.org/docs/app/building-your-application/routing/loading-ui-and-streaming) for smaller blocking times and responsive UI, without impacting SEO.
10 | - React.js [cache()](https://nextjs.org/docs/app/building-your-application/data-fetching/caching#react-cache) and [useTransition](https://nextjs.org/docs/app/building-your-application/data-fetching/server-actions#custom-invocation-using-starttransition), among other new React 18 APIs.
11 | - [New Next.js metadata API](https://nextjs.org/docs/app/api-reference/file-conventions/metadata) for easier SEO.
12 | - [Drizzle ORM](https://orm.drizzle.team/) for operating SQL database.
13 | - Planetscale's [databasejs](https://github.com/planetscale/database-js) serverless database driver.
14 |
15 |
16 | The goal for this project is to **learn and use all new features from Next 13**, coming from Next 12.
17 |
18 | ### Authentication with Clerk
19 | I set up authentication with [Clerk](https://clerk.com/), while also having my database's own user table by consuming their [webhooks.](https://clerk.com/docs/integration/webhooks)
20 |
21 | Whenever a user is created, updated or deleted, a webhook is sent to `/app/api/webhooks/user/route.ts`, and we update the info in the project's Planetscale database.
22 |
--------------------------------------------------------------------------------
/src/components/ui/comments/comment-section.tsx:
--------------------------------------------------------------------------------
1 | "use server";
2 |
3 | import { db } from "@/db";
4 | import { type CommentsTable, comments } from "@/db/schema";
5 | import { desc, eq } from "drizzle-orm";
6 | import { CommentsList } from "./comments-list";
7 |
8 | type Comment = CommentsTable;
9 |
10 | export type CommentWithChildren = Comment & {
11 | children: CommentWithChildren[];
12 | };
13 |
14 | /**
15 | * Format comments from the database and group them with their
16 | * children/parents before rendering.
17 | */
18 | function formatComments(comments: Array) {
19 | const map = new Map();
20 |
21 | const commentsWithChildren: CommentWithChildren[] = comments?.map(
22 | (comment) => ({
23 | ...comment,
24 | children: [],
25 | })
26 | );
27 |
28 | const roots: Array = commentsWithChildren?.filter(
29 | (comment) => comment.parentId === null
30 | );
31 |
32 | commentsWithChildren?.forEach((comment, i) => {
33 | map.set(comment.id, i);
34 | });
35 |
36 | for (let i = 0; i < comments.length; i++) {
37 | if (typeof commentsWithChildren[i]?.parentId === "number") {
38 | const parentCommentIndex = map.get(
39 | commentsWithChildren[i].parentId as number
40 | ) as number;
41 |
42 | commentsWithChildren[parentCommentIndex]?.children.push(
43 | commentsWithChildren[i]
44 | );
45 |
46 | continue;
47 | }
48 |
49 | continue;
50 | }
51 |
52 | return roots;
53 | }
54 |
55 | const getComments = async (postId: number) => {
56 | return await db.query.comments.findMany({
57 | where: eq(comments.postId, postId),
58 | orderBy: [desc(comments.createdAt)],
59 | });
60 | };
61 |
62 | export default async function CommentSection({ postId }: { postId: number }) {
63 | const comments = await getComments(postId);
64 | const formattedComments = formatComments(comments);
65 |
66 | return (
67 |
68 |
69 |
70 | );
71 | }
72 |
--------------------------------------------------------------------------------
/src/app/layout.tsx:
--------------------------------------------------------------------------------
1 | import type { Metadata } from "next";
2 | import { Inter } from "next/font/google";
3 | import { cn } from "@/lib/utils";
4 | import { siteConfig } from "@/config/site";
5 | import { Header } from "@/components/ui/header";
6 | import { Providers } from "./providers";
7 |
8 | import "./globals.css";
9 | import { ClerkProvider } from "@clerk/nextjs";
10 |
11 | const inter = Inter({ subsets: ["latin"] });
12 |
13 | export const metadata: Metadata = {
14 | metadataBase: new URL(siteConfig.url),
15 | title: {
16 | default: siteConfig.name,
17 | template: `${siteConfig.name} | %s`,
18 | },
19 | description: siteConfig.description,
20 | keywords: [
21 | "Next.js",
22 | "React",
23 | "Tailwind CSS",
24 | "Server Components",
25 | "Server Actions",
26 | "Ribbit",
27 | ],
28 | authors: [
29 | {
30 | name: "leojuriolli7",
31 | url: "https://github.com/leojuriolli7",
32 | },
33 | ],
34 | creator: "leojuriolli7",
35 | themeColor: [
36 | { media: "(prefers-color-scheme: light)", color: "white" },
37 | { media: "(prefers-color-scheme: dark)", color: "black" },
38 | ],
39 | openGraph: {
40 | type: "website",
41 | locale: "en_US",
42 | url: siteConfig.url,
43 | title: siteConfig.name,
44 | description: siteConfig.description,
45 | siteName: siteConfig.name,
46 | },
47 | twitter: {
48 | card: "summary_large_image",
49 | title: siteConfig.name,
50 | description: siteConfig.description,
51 | images: [`${siteConfig.url}/opengraph-image.jpg`],
52 | },
53 | };
54 |
55 | export default function RootLayout({
56 | children,
57 | }: {
58 | children: React.ReactNode;
59 | }) {
60 | return (
61 |
62 |
63 |
64 |
65 |
66 |
67 | {children}
68 |
69 |
70 |
71 |
72 |
73 | );
74 | }
75 |
--------------------------------------------------------------------------------
/src/db/schema.ts:
--------------------------------------------------------------------------------
1 | import { relations, type InferModel } from "drizzle-orm";
2 | import {
3 | int,
4 | mysqlTable,
5 | serial,
6 | text,
7 | timestamp,
8 | varchar,
9 | } from "drizzle-orm/mysql-core";
10 |
11 | export const users = mysqlTable("users", {
12 | id: serial("id").primaryKey(),
13 | clerkId: varchar("userId", { length: 191 }).notNull(),
14 | username: text("username").notNull().default("Anon"),
15 | email: text("email").notNull(),
16 | bio: text("bio"),
17 | firstName: text("firstName"),
18 | lastName: text("lastName"),
19 | imageUrl: text("imageUrl")
20 | .notNull()
21 | .default("https://ribbit-zeta.vercel.app/images/avatar.webp"),
22 | createdAt: timestamp("createdAt").defaultNow(),
23 | });
24 |
25 | export const usersRelations = relations(users, ({ many }) => ({
26 | posts: many(posts),
27 | }));
28 |
29 | export const posts = mysqlTable("posts", {
30 | id: serial("id").primaryKey(),
31 | userId: int("userId").notNull(),
32 | title: varchar("name", { length: 191 }).notNull(),
33 | slug: text("slug").notNull(),
34 | description: text("description").notNull(),
35 | createdAt: timestamp("createdAt").defaultNow(),
36 | });
37 |
38 | export type PostTable = InferModel;
39 |
40 | export const comments = mysqlTable("comments", {
41 | id: serial("id").primaryKey(),
42 | parentId: int("parentId"),
43 | text: text("text").notNull(),
44 | userId: int("userId").notNull(),
45 | postId: int("postId"),
46 | createdAt: timestamp("createdAt").defaultNow(),
47 | });
48 |
49 | export type CommentsTable = InferModel;
50 |
51 | export const commentsParentRelation = relations(comments, ({ one, many }) => ({
52 | parent: one(comments, {
53 | fields: [comments.parentId],
54 | references: [comments.id],
55 | relationName: "comment_children",
56 | }),
57 | children: many(comments),
58 | }));
59 |
60 | export const commentsRelations = relations(comments, ({ one }) => ({
61 | post: one(posts, {
62 | fields: [comments.postId],
63 | references: [posts.id],
64 | }),
65 | }));
66 |
67 | export const postsRelations = relations(posts, ({ many, one }) => ({
68 | comments: many(comments),
69 | author: one(users, {
70 | fields: [posts.userId],
71 | references: [users.id],
72 | }),
73 | }));
74 |
--------------------------------------------------------------------------------
/src/components/ui/card.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 |
3 | import { cn } from "@/lib/utils";
4 |
5 | const Card = React.forwardRef<
6 | HTMLDivElement,
7 | React.HTMLAttributes
8 | >(({ className, ...props }, ref) => (
9 |
17 | ));
18 | Card.displayName = "Card";
19 |
20 | const CardHeader = React.forwardRef<
21 | HTMLDivElement,
22 | React.HTMLAttributes
23 | >(({ className, ...props }, ref) => (
24 |
29 | ));
30 | CardHeader.displayName = "CardHeader";
31 |
32 | const CardTitle = React.forwardRef<
33 | HTMLParagraphElement,
34 | React.HTMLAttributes
35 | >(({ className, ...props }, ref) => (
36 |
41 | ));
42 | CardTitle.displayName = "CardTitle";
43 |
44 | const CardDescription = React.forwardRef<
45 | HTMLParagraphElement,
46 | React.HTMLAttributes
47 | >(({ className, ...props }, ref) => (
48 |
53 | ));
54 | CardDescription.displayName = "CardDescription";
55 |
56 | const CardContent = React.forwardRef<
57 | HTMLDivElement,
58 | React.HTMLAttributes
59 | >(({ className, ...props }, ref) => (
60 |
61 | ));
62 | CardContent.displayName = "CardContent";
63 |
64 | const CardFooter = React.forwardRef<
65 | HTMLDivElement,
66 | React.HTMLAttributes
67 | >(({ className, ...props }, ref) => (
68 |
73 | ));
74 | CardFooter.displayName = "CardFooter";
75 |
76 | export {
77 | Card,
78 | CardHeader,
79 | CardFooter,
80 | CardTitle,
81 | CardDescription,
82 | CardContent,
83 | };
84 |
--------------------------------------------------------------------------------
/src/components/ui/text.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { type VariantProps, cva } from "class-variance-authority";
3 | import { cn } from "@/lib/utils";
4 |
5 | const textVariants = cva("text-foreground", {
6 | variants: {
7 | variant: {
8 | h1: "scroll-m-20 text-4xl font-extrabold tracking-tight lg:text-5xl",
9 | h2: "scroll-m-20 pb-2 text-3xl font-semibold tracking-tight first:mt-0",
10 | h3: "scroll-m-20 text-2xl font-semibold tracking-tight",
11 | h4: "scroll-m-20 text-xl font-semibold tracking-tight",
12 | h5: "scroll-m-20 text-lg font-semibold tracking-tight",
13 | h6: "scroll-m-20 text-base font-semibold tracking-tight",
14 | p: "leading-7 [&:not(:first-child)]:mt-6",
15 | blockquote: "mt-6 border-l-2 pl-6 italic",
16 | ul: "my-6 ml-6 list-disc [&>li]:mt-2",
17 | inlineCode:
18 | "relative rounded bg-muted px-[0.3rem] py-[0.2rem] font-mono text-sm font-semibold",
19 | lead: "text-xl text-muted-foreground",
20 | largeText: "text-lg font-semibold",
21 | smallText: "text-sm font-medium leading-none",
22 | mutedText: "text-sm text-muted-foreground",
23 | },
24 | },
25 | });
26 |
27 | type VariantPropType = VariantProps;
28 |
29 | const variantElementMap: Record<
30 | NonNullable,
31 | string
32 | > = {
33 | h1: "h1",
34 | h2: "h2",
35 | h3: "h3",
36 | h4: "h4",
37 | h5: "h5",
38 | h6: "h6",
39 | p: "p",
40 | blockquote: "blockquote",
41 | inlineCode: "code",
42 | largeText: "div",
43 | smallText: "small",
44 | lead: "p",
45 | mutedText: "p",
46 | ul: "ul",
47 | };
48 |
49 | type Element = keyof JSX.IntrinsicElements;
50 |
51 | type TextProps = {
52 | as?: T;
53 | ref?: React.ForwardedRef;
54 | } & VariantPropType &
55 | React.HTMLAttributes;
56 |
57 | const Text = ({
58 | className,
59 | as,
60 | variant,
61 | ...props
62 | }: TextProps) => {
63 | const Component =
64 | as ?? (variant ? variantElementMap[variant] : undefined) ?? "div";
65 |
66 | const componentProps = {
67 | className: cn(textVariants({ variant, className })),
68 | ...props,
69 | };
70 |
71 | return React.createElement(Component, componentProps);
72 | };
73 |
74 | export default React.forwardRef>((props, ref) => (
75 |
76 | ));
77 |
--------------------------------------------------------------------------------
/src/components/ui/button.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { Slot } from "@radix-ui/react-slot";
3 | import { cva, type VariantProps } from "class-variance-authority";
4 |
5 | import { cn } from "@/lib/utils";
6 |
7 | const buttonVariants = cva(
8 | "inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-zinc-400 disabled:pointer-events-none disabled:opacity-50 dark:focus-visible:ring-zinc-800",
9 | {
10 | variants: {
11 | variant: {
12 | brand:
13 | "bg-green-500 text-zinc-50 shadow hover:bg-green-500/90 dark:bg-green-600 dark:hover:bg-green-600/90",
14 | default:
15 | "bg-zinc-900 text-zinc-50 shadow hover:bg-zinc-900/90 dark:bg-zinc-50 dark:text-zinc-900 dark:hover:bg-zinc-50/90",
16 | destructive:
17 | "bg-red-500 text-zinc-50 shadow-sm hover:bg-red-500/90 dark:bg-red-900 dark:text-red-50 dark:hover:bg-red-900/90",
18 | outline:
19 | "border border-zinc-200 bg-white shadow-sm hover:bg-zinc-100 hover:text-zinc-900 dark:border-zinc-800 dark:bg-zinc-950 dark:hover:bg-zinc-800 dark:hover:text-zinc-50",
20 | secondary:
21 | "bg-zinc-100 text-zinc-900 shadow-sm hover:bg-zinc-100/80 dark:bg-zinc-800 dark:text-zinc-50 dark:hover:bg-zinc-800/80",
22 | ghost:
23 | "hover:bg-zinc-100 hover:text-zinc-900 dark:hover:bg-zinc-800 dark:hover:text-zinc-50",
24 | link: "text-zinc-900 underline-offset-4 hover:underline dark:text-zinc-50",
25 | },
26 | size: {
27 | default: "h-9 px-4 py-2",
28 | sm: "h-8 rounded-md px-3 text-xs",
29 | lg: "h-10 rounded-md px-8",
30 | icon: "h-9 w-9",
31 | },
32 | },
33 | defaultVariants: {
34 | variant: "default",
35 | size: "default",
36 | },
37 | }
38 | );
39 |
40 | export interface ButtonProps
41 | extends React.ButtonHTMLAttributes,
42 | VariantProps {
43 | asChild?: boolean;
44 | }
45 |
46 | const Button = React.forwardRef(
47 | ({ className, variant, size, asChild = false, ...props }, ref) => {
48 | const Comp = asChild ? Slot : "button";
49 | return (
50 |
55 | );
56 | }
57 | );
58 | Button.displayName = "Button";
59 |
60 | export { Button, buttonVariants };
61 |
--------------------------------------------------------------------------------
/src/components/forms/create-post-form.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useForm } from "react-hook-form";
4 | import { zodResolver } from "@hookform/resolvers/zod";
5 | import {
6 | type CreatePostInput,
7 | createPostSchema,
8 | } from "@/lib/validations/post.schema";
9 | import { useTransition } from "react";
10 | import { createPostAction } from "@/app/_actions/post.actions";
11 | import { Button } from "../ui/button";
12 | import { Input } from "../ui/input";
13 | import { Textarea } from "../ui/textarea";
14 | import {
15 | Form,
16 | FormControl,
17 | FormField,
18 | FormItem,
19 | FormLabel,
20 | FormMessage,
21 | } from "../ui/form";
22 | import { useUser } from "@clerk/nextjs";
23 |
24 | export const CreatePostForm = () => {
25 | const { user } = useUser();
26 | const userId = user?.publicMetadata.databaseId;
27 |
28 | const [isPending, startTransition] = useTransition();
29 |
30 | const methods = useForm({
31 | resolver: zodResolver(createPostSchema),
32 | defaultValues: {
33 | description: undefined,
34 | title: undefined,
35 | userId,
36 | },
37 | });
38 |
39 | const { control, handleSubmit } = methods;
40 |
41 | const onSubmit = (values: CreatePostInput) => {
42 | startTransition(async () => {
43 | await createPostAction(values);
44 | });
45 | };
46 |
47 | return (
48 |
91 |
92 | );
93 | };
94 |
--------------------------------------------------------------------------------
/src/components/forms/edit-comment-form.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useForm } from "react-hook-form";
4 | import { zodResolver } from "@hookform/resolvers/zod";
5 | import {
6 | type EditCommentInput,
7 | editCommentSchema,
8 | } from "@/lib/validations/comment.schema";
9 | import { useTransition } from "react";
10 | import { editCommentAction } from "@/app/_actions/comment.actions";
11 | import { Button } from "../ui/button";
12 | import { Textarea } from "../ui/textarea";
13 | import {
14 | Form,
15 | FormControl,
16 | FormField,
17 | FormItem,
18 | FormLabel,
19 | FormMessage,
20 | } from "../ui/form";
21 | import { usePathname, useRouter } from "next/navigation";
22 | import Link from "next/link";
23 |
24 | type Props = {
25 | slug: string;
26 | text: string;
27 | commentId: number;
28 | };
29 |
30 | export const EditCommentForm = ({ text, slug, commentId }: Props) => {
31 | const [isUpdating, startTransition] = useTransition();
32 | const router = useRouter();
33 | const pathname = usePathname();
34 |
35 | const methods = useForm({
36 | resolver: zodResolver(editCommentSchema),
37 | defaultValues: {
38 | text,
39 | slug,
40 | commentId,
41 | },
42 | });
43 |
44 | const { control, handleSubmit } = methods;
45 |
46 | const onSubmit = (values: EditCommentInput) => {
47 | startTransition(async () => {
48 | await editCommentAction(values);
49 |
50 | router.replace(pathname, { scroll: false });
51 | });
52 | };
53 |
54 | return (
55 |
95 |
96 | );
97 | };
98 |
--------------------------------------------------------------------------------
/src/components/forms/edit-post-form.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useForm } from "react-hook-form";
4 | import { zodResolver } from "@hookform/resolvers/zod";
5 | import {
6 | type EditPostInput,
7 | editPostSchema,
8 | } from "@/lib/validations/post.schema";
9 | import { useTransition } from "react";
10 | import { editPostAction } from "@/app/_actions/post.actions";
11 | import { Button } from "../ui/button";
12 | import { Input } from "../ui/input";
13 | import { Textarea } from "../ui/textarea";
14 | import {
15 | Form,
16 | FormControl,
17 | FormField,
18 | FormItem,
19 | FormLabel,
20 | FormMessage,
21 | } from "../ui/form";
22 | import { useParams, usePathname, useRouter } from "next/navigation";
23 | import { useUser } from "@clerk/nextjs";
24 | import Link from "next/link";
25 |
26 | type Props = {
27 | title?: string;
28 | description?: string;
29 | };
30 |
31 | export const EditPostForm = ({ title, description }: Props) => {
32 | const [isPending, startTransition] = useTransition();
33 | const { slug } = useParams();
34 | const pathname = usePathname();
35 | const router = useRouter();
36 |
37 | const { user } = useUser();
38 | const userId = user?.publicMetadata.databaseId;
39 |
40 | const methods = useForm({
41 | resolver: zodResolver(editPostSchema),
42 | defaultValues: {
43 | description,
44 | title,
45 | slug,
46 | userId,
47 | },
48 | });
49 |
50 | const { control, handleSubmit } = methods;
51 |
52 | const onSubmit = (values: EditPostInput) => {
53 | startTransition(async () => {
54 | await editPostAction(values);
55 | });
56 | router.replace(pathname);
57 | };
58 |
59 | return (
60 |
117 |
118 | );
119 | };
120 |
--------------------------------------------------------------------------------
/src/app/posts/[slug]/page.tsx:
--------------------------------------------------------------------------------
1 | import { Suspense, cache } from "react";
2 | import { DeletePostButton } from "@/components/ui/delete-post-button";
3 | import Text from "@/components/ui/text";
4 | import { db } from "@/db";
5 | import { posts } from "@/db/schema";
6 | import { eq } from "drizzle-orm";
7 | import type { Metadata } from "next";
8 | import { notFound } from "next/navigation";
9 | import { currentUser } from "@clerk/nextjs";
10 | import { EditPostButton } from "@/components/ui/edit-post-button";
11 | import { EditPostForm } from "@/components/forms/edit-post-form";
12 | import PostAuthor from "@/components/ui/post-author";
13 | import { CreateCommentForm } from "@/components/forms/create-comment-form";
14 | import CommentSection from "@/components/ui/comments/comment-section";
15 | import { CommentSectionSkeleton } from "@/components/ui/comments/comment-section-skeleton";
16 | import { PostAuthorSkeleton } from "@/components/ui/post-author-skeleton";
17 |
18 | export const runtime = "edge";
19 |
20 | const getPost = cache(async (slug: string) => {
21 | if (!slug) notFound();
22 |
23 | const post = await db.query.posts.findFirst({
24 | where: eq(posts.slug, slug),
25 | });
26 |
27 | if (!post) notFound();
28 |
29 | return post;
30 | });
31 |
32 | export async function generateMetadata({
33 | params,
34 | }: {
35 | params: {
36 | slug: string;
37 | };
38 | }): Promise {
39 | const slug = params.slug;
40 | const post = await getPost(slug);
41 |
42 | return {
43 | title: post.title,
44 | description: post.description,
45 | twitter: {
46 | title: post.title,
47 | description: post.description,
48 | },
49 | };
50 | }
51 |
52 | export default async function PostPage({
53 | params,
54 | searchParams,
55 | }: {
56 | params: { slug: string };
57 | searchParams: {
58 | edit: string;
59 | };
60 | }) {
61 | const post = await getPost(params?.slug);
62 |
63 | const user = await currentUser();
64 | const userIsOP = !!post && user?.publicMetadata.databaseId === post?.userId;
65 |
66 | const showEditForm = !!searchParams.edit && userIsOP;
67 |
68 | return (
69 |
70 |
71 | {showEditForm ? (
72 |
73 | ) : (
74 | <>
75 | {post?.title}
76 | {/* Streaming: this will load after the post loads, without blocking the page from loading. */}
77 | }>
78 |
79 |
80 |
81 | {post?.description}
82 |
83 | >
84 | )}
85 |
86 |
87 | {userIsOP && (
88 |
89 |
90 |
91 |
92 |
93 | )}
94 |
95 |
96 |
Comments
97 |
98 |
99 |
100 |
101 |
102 |
103 | }>
104 |
105 |
106 |
107 | );
108 | }
109 |
--------------------------------------------------------------------------------
/src/components/forms/create-comment-form.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useForm } from "react-hook-form";
4 | import { zodResolver } from "@hookform/resolvers/zod";
5 | import {
6 | type CreateCommentInput,
7 | createCommentSchema,
8 | } from "@/lib/validations/comment.schema";
9 | import { useTransition } from "react";
10 | import { createCommentAction } from "@/app/_actions/comment.actions";
11 | import { Button } from "../ui/button";
12 | import { Textarea } from "../ui/textarea";
13 | import {
14 | Form,
15 | FormControl,
16 | FormField,
17 | FormItem,
18 | FormLabel,
19 | FormMessage,
20 | } from "../ui/form";
21 | import { useUser } from "@clerk/nextjs";
22 | import { usePathname, useRouter } from "next/navigation";
23 | import { cn } from "@/lib/utils";
24 | import Link from "next/link";
25 |
26 | type Props = {
27 | slug: string;
28 | postId: number;
29 | parentId?: number;
30 | };
31 |
32 | export const CreateCommentForm = ({ postId, slug, parentId }: Props) => {
33 | const { user, isSignedIn } = useUser();
34 | const userId = user?.publicMetadata.databaseId;
35 |
36 | const [isCreatingComment, startTransition] = useTransition();
37 | const router = useRouter();
38 | const pathname = usePathname();
39 |
40 | const isReply = !!parentId;
41 |
42 | const methods = useForm({
43 | resolver: zodResolver(createCommentSchema),
44 | defaultValues: {
45 | text: undefined,
46 | userId,
47 | postId,
48 | slug,
49 | parentId,
50 | },
51 | });
52 |
53 | const { control, handleSubmit, setValue } = methods;
54 | const signedInTitle = isReply ? "Type your reply" : "Join the conversation";
55 | const signedOutTitle = "Login to post a comment!";
56 | const signedOutButtonMessage = "Can't comment while logged out";
57 | const signedInButtonMesage = isReply ? "Reply" : "Post comment";
58 |
59 | const onSubmit = (values: CreateCommentInput) => {
60 | startTransition(async () => {
61 | await createCommentAction(values);
62 |
63 | setValue("text", "");
64 | router.replace(pathname, { scroll: false });
65 | });
66 | };
67 |
68 | return (
69 |
118 |
119 | );
120 | };
121 |
--------------------------------------------------------------------------------
/src/components/ui/comments/comment-options.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import Link from "next/link";
4 | import {
5 | useParams,
6 | usePathname,
7 | useRouter,
8 | useSearchParams,
9 | } from "next/navigation";
10 | import Text from "../text";
11 | import { CreateCommentForm } from "../../forms/create-comment-form";
12 | import { useTransition } from "react";
13 | import { useUser } from "@clerk/nextjs";
14 | import { deleteCommentAction } from "@/app/_actions/comment.actions";
15 | import { ConfirmDialog } from "../confirm-dialog";
16 | import { EditCommentForm } from "@/components/forms/edit-comment-form";
17 |
18 | type Props = {
19 | authorId: number;
20 | commentId: number;
21 | postId: number;
22 | commentText: string;
23 | };
24 |
25 | /** Reply and delete options. */
26 | export const CommentOptions = ({
27 | commentId,
28 | postId,
29 | authorId,
30 | commentText,
31 | }: Props) => {
32 | const pathname = usePathname();
33 | const searchParams = useSearchParams();
34 | const { slug } = useParams();
35 | const router = useRouter();
36 |
37 | const [deleting, startDeleting] = useTransition();
38 |
39 | const { user } = useUser();
40 | const userId = user?.publicMetadata.databaseId;
41 | const userIsAuthor = !!userId && userId === authorId;
42 |
43 | const onClickDelete = () =>
44 | startDeleting(async () => {
45 | await deleteCommentAction({
46 | idToDelete: commentId,
47 | postSlug: slug,
48 | });
49 |
50 | router.replace(pathname, { scroll: false });
51 | });
52 |
53 | const currentlyReplyingTo = searchParams.get("replyingTo");
54 | const showReplyForm = currentlyReplyingTo === String(commentId);
55 |
56 | const getReplyHref = () => {
57 | // if reply form is showing and user clicks, hide it
58 | if (showReplyForm) return pathname;
59 |
60 | return `${pathname}?replyingTo=${commentId}`;
61 | };
62 |
63 | const replyHref = getReplyHref();
64 |
65 | const currentlyEditing = searchParams.get("editingComment");
66 | const showEditForm = currentlyEditing === String(commentId);
67 |
68 | const getEditHref = () => {
69 | // if reply form is showing and user clicks, hide it
70 | if (showEditForm) return pathname;
71 |
72 | return `${pathname}?editingComment=${commentId}`;
73 | };
74 |
75 | const editHref = getEditHref();
76 |
77 | return (
78 |
79 | {showEditForm && (
80 |
81 | )}
82 |
83 |
84 |
85 |
86 | {showReplyForm ? "Stop replying" : "Reply"}
87 |
88 |
89 |
90 | {userIsAuthor && (
91 | <>
92 |
93 |
94 | {showEditForm ? "Stop editing" : "Edit"}
95 |
96 |
97 |
98 |
110 | Delete
111 |
112 | }
113 | />
114 | >
115 | )}
116 |
117 |
118 | {showReplyForm && (
119 |
120 | )}
121 |
122 | );
123 | };
124 |
--------------------------------------------------------------------------------
/src/app/api/webhooks/user/route.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable @typescript-eslint/no-unsafe-assignment */
2 | import type { User } from "@clerk/nextjs/api";
3 | import { headers } from "next/headers";
4 | import { Webhook } from "svix";
5 | import { db } from "@/db";
6 | import { users } from "@/db/schema";
7 | import { eq } from "drizzle-orm";
8 | import { clerkClient } from "@clerk/nextjs";
9 |
10 | // these keys are not returned by the webhooks.
11 | type UnwantedKeys =
12 | | "emailAddresses"
13 | | "firstName"
14 | | "lastName"
15 | | "primaryEmailAddressId"
16 | | "primaryPhoneNumberId"
17 | | "phoneNumbers";
18 |
19 | // object with data returned from webhook.
20 | // can verify this in Clerk dashboard webhook logs.
21 | interface UserInterface extends Omit {
22 | email_addresses: {
23 | email_address: string;
24 | id: string;
25 | }[];
26 | username: string;
27 | primary_email_address_id: string;
28 | first_name: string | null;
29 | last_name: string | null;
30 | image_url: string;
31 | }
32 |
33 | export const runtime = "edge";
34 |
35 | const webhookSecret: string = process.env.WEBHOOK_SECRET || "";
36 |
37 | /**
38 | * This API Route will be hit by the Clerk webhook whenever a user
39 | * is created, updated or deleted.
40 | */
41 | export async function POST(req: Request) {
42 | const payload = await req.json();
43 | const payloadString = JSON.stringify(payload);
44 | const headerPayload = headers();
45 | const svixId = headerPayload.get("svix-id");
46 | const svixIdTimeStamp = headerPayload.get("svix-timestamp");
47 | const svixSignature = headerPayload.get("svix-signature");
48 | if (!svixId || !svixIdTimeStamp || !svixSignature) {
49 | console.log("svixId", svixId);
50 | console.log("svixIdTimeStamp", svixIdTimeStamp);
51 | console.log("svixSignature", svixSignature);
52 | return new Response("Error occured", {
53 | status: 400,
54 | });
55 | }
56 | const svixHeaders = {
57 | "svix-id": svixId,
58 | "svix-timestamp": svixIdTimeStamp,
59 | "svix-signature": svixSignature,
60 | };
61 | const wh = new Webhook(webhookSecret);
62 | let evt: Event | null = null;
63 | try {
64 | evt = wh.verify(payloadString, svixHeaders) as Event;
65 | } catch (_) {
66 | console.log("error");
67 | return new Response("Error occured", {
68 | status: 400,
69 | });
70 | }
71 | // Handle the webhook
72 | const eventType: EventType = evt.type;
73 | if (eventType === "user.created" || eventType === "user.updated") {
74 | const {
75 | id,
76 | first_name: firstName,
77 | username,
78 | image_url: imageUrl,
79 | email_addresses,
80 | primary_email_address_id,
81 | last_name: lastName,
82 | } = evt.data;
83 |
84 | const emailObject = email_addresses?.find((email) => {
85 | return email.id === primary_email_address_id;
86 | });
87 | if (!emailObject) {
88 | return new Response("Error locating user", {
89 | status: 400,
90 | });
91 | }
92 |
93 | const primaryEmail = emailObject.email_address;
94 |
95 | const exists = await db.query.users.findFirst({
96 | where: eq(users.clerkId, id),
97 | });
98 |
99 | // If the user already exists in the database, we only update it.
100 | if (!!exists) {
101 | await db
102 | .update(users)
103 | .set({
104 | firstName,
105 | lastName,
106 | username,
107 | imageUrl,
108 | email: primaryEmail,
109 | })
110 | .where(eq(users.clerkId, id));
111 | } else {
112 | // if there is no user in the db, create one.
113 | await db.insert(users).values({
114 | firstName,
115 | lastName,
116 | email: primaryEmail,
117 | username,
118 | imageUrl,
119 | clerkId: id,
120 | });
121 |
122 | const createdUser = await db.query.users.findFirst({
123 | where: eq(users.clerkId, id),
124 | });
125 |
126 | /**
127 | * After a user is created in the database, we get the database id
128 | * and assign it to the clerk user object's metadata, so we can easily
129 | * access it anywhere.
130 | */
131 | if (createdUser) {
132 | await clerkClient.users.updateUserMetadata(id, {
133 | publicMetadata: {
134 | databaseId: createdUser.id,
135 | },
136 | });
137 | }
138 | }
139 | }
140 | if (eventType === "user.deleted") {
141 | const { id } = evt.data;
142 |
143 | await db.delete(users).where(eq(users.clerkId, id));
144 | }
145 |
146 | return new Response("", {
147 | status: 201,
148 | });
149 | }
150 |
151 | type Event = {
152 | data: UserInterface;
153 | object: "event";
154 | type: EventType;
155 | };
156 |
157 | type EventType = "user.created" | "user.updated" | "user.deleted" | "*";
158 |
--------------------------------------------------------------------------------
/src/components/ui/dialog.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as React from "react";
4 | import * as DialogPrimitive from "@radix-ui/react-dialog";
5 | import { Cross2Icon } from "@radix-ui/react-icons";
6 |
7 | import { cn } from "@/lib/utils";
8 |
9 | const Dialog = DialogPrimitive.Root;
10 |
11 | const DialogTrigger = DialogPrimitive.Trigger;
12 |
13 | const DialogPortal = ({
14 | className,
15 | ...props
16 | }: DialogPrimitive.DialogPortalProps) => (
17 |
18 | );
19 | DialogPortal.displayName = DialogPrimitive.Portal.displayName;
20 |
21 | const DialogOverlay = React.forwardRef<
22 | React.ElementRef,
23 | React.ComponentPropsWithoutRef
24 | >(({ className, ...props }, ref) => (
25 |
33 | ));
34 | DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
35 |
36 | const DialogContent = React.forwardRef<
37 | React.ElementRef,
38 | React.ComponentPropsWithoutRef & {
39 | hideCloseButton?: boolean;
40 | }
41 | >(({ className, children, hideCloseButton, ...props }, ref) => (
42 |
43 |
44 |
52 | {children}
53 | {!hideCloseButton && (
54 |
55 |
56 | Close
57 |
58 | )}
59 |
60 |
61 | ));
62 | DialogContent.displayName = DialogPrimitive.Content.displayName;
63 |
64 | const DialogHeader = ({
65 | className,
66 | ...props
67 | }: React.HTMLAttributes) => (
68 |
75 | );
76 | DialogHeader.displayName = "DialogHeader";
77 |
78 | const DialogFooter = ({
79 | className,
80 | ...props
81 | }: React.HTMLAttributes) => (
82 |
89 | );
90 | DialogFooter.displayName = "DialogFooter";
91 |
92 | const DialogTitle = React.forwardRef<
93 | React.ElementRef,
94 | React.ComponentPropsWithoutRef
95 | >(({ className, ...props }, ref) => (
96 |
104 | ));
105 | DialogTitle.displayName = DialogPrimitive.Title.displayName;
106 |
107 | const DialogDescription = React.forwardRef<
108 | React.ElementRef,
109 | React.ComponentPropsWithoutRef
110 | >(({ className, ...props }, ref) => (
111 |
116 | ));
117 | DialogDescription.displayName = DialogPrimitive.Description.displayName;
118 |
119 | export {
120 | Dialog,
121 | DialogTrigger,
122 | DialogContent,
123 | DialogHeader,
124 | DialogFooter,
125 | DialogTitle,
126 | DialogDescription,
127 | };
128 |
--------------------------------------------------------------------------------
/src/components/ui/form.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import type * as LabelPrimitive from "@radix-ui/react-label";
3 | import { Slot } from "@radix-ui/react-slot";
4 | import {
5 | Controller,
6 | type ControllerProps,
7 | type FieldPath,
8 | type FieldValues,
9 | FormProvider,
10 | useFormContext,
11 | } from "react-hook-form";
12 |
13 | import { cn } from "@/lib/utils";
14 | import { Label } from "@/components/ui/label";
15 |
16 | const Form = FormProvider;
17 |
18 | type FormFieldContextValue<
19 | TFieldValues extends FieldValues = FieldValues,
20 | TName extends FieldPath = FieldPath
21 | > = {
22 | name: TName;
23 | };
24 |
25 | const FormFieldContext = React.createContext(
26 | {} as FormFieldContextValue
27 | );
28 |
29 | const FormField = <
30 | TFieldValues extends FieldValues = FieldValues,
31 | TName extends FieldPath = FieldPath
32 | >({
33 | ...props
34 | }: ControllerProps) => {
35 | return (
36 |
37 |
38 |
39 | );
40 | };
41 |
42 | const useFormField = () => {
43 | const fieldContext = React.useContext(FormFieldContext);
44 | const itemContext = React.useContext(FormItemContext);
45 | const { getFieldState, formState } = useFormContext();
46 |
47 | const fieldState = getFieldState(fieldContext.name, formState);
48 |
49 | if (!fieldContext) {
50 | throw new Error("useFormField should be used within ");
51 | }
52 |
53 | const { id } = itemContext;
54 |
55 | return {
56 | id,
57 | name: fieldContext.name,
58 | formItemId: `${id}-form-item`,
59 | formDescriptionId: `${id}-form-item-description`,
60 | formMessageId: `${id}-form-item-message`,
61 | ...fieldState,
62 | };
63 | };
64 |
65 | type FormItemContextValue = {
66 | id: string;
67 | };
68 |
69 | const FormItemContext = React.createContext(
70 | {} as FormItemContextValue
71 | );
72 |
73 | const FormItem = React.forwardRef<
74 | HTMLDivElement,
75 | React.HTMLAttributes
76 | >(({ className, ...props }, ref) => {
77 | const id = React.useId();
78 |
79 | return (
80 |
81 |
82 |
83 | );
84 | });
85 | FormItem.displayName = "FormItem";
86 |
87 | const FormLabel = React.forwardRef<
88 | React.ElementRef,
89 | React.ComponentPropsWithoutRef
90 | >(({ className, ...props }, ref) => {
91 | const { formItemId } = useFormField();
92 |
93 | return (
94 |
95 | );
96 | });
97 | FormLabel.displayName = "FormLabel";
98 |
99 | const FormControl = React.forwardRef<
100 | React.ElementRef,
101 | React.ComponentPropsWithoutRef
102 | >(({ ...props }, ref) => {
103 | const { error, formItemId, formDescriptionId, formMessageId } =
104 | useFormField();
105 |
106 | return (
107 |
118 | );
119 | });
120 | FormControl.displayName = "FormControl";
121 |
122 | const FormDescription = React.forwardRef<
123 | HTMLParagraphElement,
124 | React.HTMLAttributes
125 | >(({ className, ...props }, ref) => {
126 | const { formDescriptionId } = useFormField();
127 |
128 | return (
129 |
138 | );
139 | });
140 | FormDescription.displayName = "FormDescription";
141 |
142 | const FormMessage = React.forwardRef<
143 | HTMLParagraphElement,
144 | React.HTMLAttributes
145 | >(({ className, children, ...props }, ref) => {
146 | const { error, formMessageId } = useFormField();
147 | const body = error ? String(error?.message) : children;
148 |
149 | if (!body) {
150 | return null;
151 | }
152 |
153 | return (
154 |
167 | {body}
168 |
169 | );
170 | });
171 | FormMessage.displayName = "FormMessage";
172 |
173 | export {
174 | useFormField,
175 | Form,
176 | FormItem,
177 | FormLabel,
178 | FormControl,
179 | FormDescription,
180 | FormMessage,
181 | FormField,
182 | };
183 |
--------------------------------------------------------------------------------
/src/components/ui/dropdown-menu.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as React from "react";
4 | import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
5 | import {
6 | CheckIcon,
7 | ChevronRightIcon,
8 | DotFilledIcon,
9 | } from "@radix-ui/react-icons";
10 | import { cn } from "@/lib/utils";
11 |
12 | const DropdownMenu = DropdownMenuPrimitive.Root;
13 |
14 | const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
15 |
16 | const DropdownMenuGroup = DropdownMenuPrimitive.Group;
17 |
18 | const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
19 |
20 | const DropdownMenuSub = DropdownMenuPrimitive.Sub;
21 |
22 | const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
23 |
24 | const DropdownMenuSubTrigger = React.forwardRef<
25 | React.ElementRef,
26 | React.ComponentPropsWithoutRef & {
27 | inset?: boolean;
28 | }
29 | >(({ className, inset, children, ...props }, ref) => (
30 |
39 | {children}
40 |
41 |
42 | ));
43 | DropdownMenuSubTrigger.displayName =
44 | DropdownMenuPrimitive.SubTrigger.displayName;
45 |
46 | const DropdownMenuSubContent = React.forwardRef<
47 | React.ElementRef,
48 | React.ComponentPropsWithoutRef
49 | >(({ className, ...props }, ref) => (
50 |
58 | ));
59 | DropdownMenuSubContent.displayName =
60 | DropdownMenuPrimitive.SubContent.displayName;
61 |
62 | const DropdownMenuContent = React.forwardRef<
63 | React.ElementRef,
64 | React.ComponentPropsWithoutRef
65 | >(({ className, sideOffset = 4, ...props }, ref) => (
66 |
67 |
77 |
78 | ));
79 | DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
80 |
81 | const DropdownMenuItem = React.forwardRef<
82 | React.ElementRef,
83 | React.ComponentPropsWithoutRef & {
84 | inset?: boolean;
85 | }
86 | >(({ className, inset, ...props }, ref) => (
87 |
96 | ));
97 | DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
98 |
99 | const DropdownMenuCheckboxItem = React.forwardRef<
100 | React.ElementRef,
101 | React.ComponentPropsWithoutRef
102 | >(({ className, children, checked, ...props }, ref) => (
103 |
112 |
113 |
114 |
115 |
116 |
117 | {children}
118 |
119 | ));
120 | DropdownMenuCheckboxItem.displayName =
121 | DropdownMenuPrimitive.CheckboxItem.displayName;
122 |
123 | const DropdownMenuRadioItem = React.forwardRef<
124 | React.ElementRef,
125 | React.ComponentPropsWithoutRef
126 | >(({ className, children, ...props }, ref) => (
127 |
135 |
136 |
137 |
138 |
139 |
140 | {children}
141 |
142 | ));
143 | DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
144 |
145 | const DropdownMenuLabel = React.forwardRef<
146 | React.ElementRef,
147 | React.ComponentPropsWithoutRef & {
148 | inset?: boolean;
149 | }
150 | >(({ className, inset, ...props }, ref) => (
151 |
160 | ));
161 | DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
162 |
163 | const DropdownMenuSeparator = React.forwardRef<
164 | React.ElementRef,
165 | React.ComponentPropsWithoutRef
166 | >(({ className, ...props }, ref) => (
167 |
172 | ));
173 | DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
174 |
175 | const DropdownMenuShortcut = ({
176 | className,
177 | ...props
178 | }: React.HTMLAttributes) => {
179 | return (
180 |
184 | );
185 | };
186 | DropdownMenuShortcut.displayName = "DropdownMenuShortcut";
187 |
188 | export {
189 | DropdownMenu,
190 | DropdownMenuTrigger,
191 | DropdownMenuContent,
192 | DropdownMenuItem,
193 | DropdownMenuCheckboxItem,
194 | DropdownMenuRadioItem,
195 | DropdownMenuLabel,
196 | DropdownMenuSeparator,
197 | DropdownMenuShortcut,
198 | DropdownMenuGroup,
199 | DropdownMenuPortal,
200 | DropdownMenuSub,
201 | DropdownMenuSubContent,
202 | DropdownMenuSubTrigger,
203 | DropdownMenuRadioGroup,
204 | };
205 |
--------------------------------------------------------------------------------