├── src
├── styles
│ └── globals.css
├── pages
│ ├── api
│ │ ├── auth
│ │ │ └── [...nextauth].ts
│ │ └── trpc
│ │ │ └── [trpc].ts
│ ├── _document.tsx
│ ├── _app.tsx
│ ├── index.tsx
│ └── posts.tsx
├── utils
│ ├── cn.ts
│ ├── zod-form.ts
│ └── api.ts
├── server
│ ├── db.ts
│ ├── api
│ │ ├── root.ts
│ │ ├── routers
│ │ │ └── post.ts
│ │ └── trpc.ts
│ └── auth.ts
├── ui
│ ├── label.tsx
│ ├── input.tsx
│ ├── text-area.tsx
│ ├── avatar.tsx
│ ├── button.tsx
│ ├── select.tsx
│ └── dialog.tsx
└── env.mjs
├── public
└── favicon.ico
├── .prettierignore
├── postcss.config.cjs
├── renovate.json
├── .vscode
├── extensions.json
└── settings.json
├── docker-compose.yml
├── vitest.config.ts
├── e2e
├── setup
│ ├── storage-state.json
│ └── global.ts
└── smoke.test.ts
├── playwright.config.ts
├── .gitignore
├── tsconfig.json
├── next.config.mjs
├── .eslintrc.cjs
├── tailwind.config.ts
├── .env.example
├── prettier.config.cjs
├── test
└── trpc
│ └── post-router.test.ts
├── prisma
└── schema.prisma
├── .github
└── workflows
│ └── ci.yml
├── package.json
└── README.md
/src/styles/globals.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/juliusmarminge/t3-complete/HEAD/public/favicon.ico
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | pnpm-lock.yaml
2 | node_modules/**
3 | .next
4 | .vercel
5 | e2e/setup/storage-state.json
--------------------------------------------------------------------------------
/postcss.config.cjs:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | };
7 |
--------------------------------------------------------------------------------
/renovate.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json",
3 | "extends": ["config:base"],
4 | "automerge": true
5 | }
6 |
--------------------------------------------------------------------------------
/src/pages/api/auth/[...nextauth].ts:
--------------------------------------------------------------------------------
1 | import NextAuth from "next-auth";
2 |
3 | import { authOptions } from "~/server/auth";
4 |
5 | export default NextAuth(authOptions);
6 |
--------------------------------------------------------------------------------
/.vscode/extensions.json:
--------------------------------------------------------------------------------
1 | {
2 | "recommendations": [
3 | "esbenp.prettier-vscode",
4 | "dbaeumer.vscode-eslint",
5 | "bradlc.vscode-tailwindcss",
6 | "Prisma.prisma"
7 | ]
8 | }
9 |
--------------------------------------------------------------------------------
/src/utils/cn.ts:
--------------------------------------------------------------------------------
1 | import { clsx, type ClassValue } from "clsx";
2 | import { twMerge } from "tailwind-merge";
3 |
4 | export function cn(...inputs: ClassValue[]) {
5 | return twMerge(clsx(inputs));
6 | }
7 |
--------------------------------------------------------------------------------
/docker-compose.yml:
--------------------------------------------------------------------------------
1 | version: "3.9"
2 |
3 | services:
4 | db:
5 | image: postgres:16
6 | restart: always
7 | environment:
8 | POSTGRES_PASSWORD: ${PGPASSWORD}
9 | volumes:
10 | - ./prisma/data:/var/lib/postgresql/data
11 | ports:
12 | - 5432:5432
13 |
--------------------------------------------------------------------------------
/vitest.config.ts:
--------------------------------------------------------------------------------
1 | import { join } from "path";
2 | import { configDefaults, defineConfig } from "vitest/config";
3 |
4 | export default defineConfig({
5 | test: {
6 | exclude: [...configDefaults.exclude, "**/e2e/**"],
7 | },
8 | resolve: {
9 | alias: {
10 | "~/": join(__dirname, "./src/"),
11 | },
12 | },
13 | });
14 |
--------------------------------------------------------------------------------
/e2e/setup/storage-state.json:
--------------------------------------------------------------------------------
1 | {
2 | "cookies": [
3 | {
4 | "name": "next-auth.session-token",
5 | "value": "d52f0c50-b8e3-4326-b48c-4d4a66fdeb64",
6 | "domain": "localhost",
7 | "path": "/",
8 | "expires": -1,
9 | "httpOnly": true,
10 | "secure": false,
11 | "sameSite": "Lax"
12 | }
13 | ],
14 | "origins": []
15 | }
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "editor.codeActionsOnSave": {
3 | "source.fixAll.eslint": true
4 | },
5 | "editor.defaultFormatter": "esbenp.prettier-vscode",
6 | "editor.formatOnSave": true,
7 | "tailwindCSS.experimental.classRegex": [
8 | ["cva\\(([^)]*)\\)", "[\"'`]([^\"'`]*).*?[\"'`]"]
9 | ],
10 | "typescript.tsdk": "node_modules/typescript/lib"
11 | }
12 |
--------------------------------------------------------------------------------
/src/pages/_document.tsx:
--------------------------------------------------------------------------------
1 | import { Head, Html, Main, NextScript } from "next/document";
2 |
3 | export default function Document() {
4 | return (
5 |
6 |
8 |
9 |
10 |
11 |
12 | );
13 | }
14 |
--------------------------------------------------------------------------------
/src/server/db.ts:
--------------------------------------------------------------------------------
1 | import { PrismaClient } from "@prisma/client";
2 |
3 | import { env } from "~/env.mjs";
4 |
5 | const globalForPrisma = globalThis as unknown as { prisma: PrismaClient };
6 |
7 | export const prisma =
8 | globalForPrisma.prisma ||
9 | new PrismaClient({
10 | log:
11 | env.NODE_ENV === "development" ? ["query", "error", "warn"] : ["error"],
12 | });
13 |
14 | if (env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;
15 |
--------------------------------------------------------------------------------
/src/utils/zod-form.ts:
--------------------------------------------------------------------------------
1 | import { zodResolver } from "@hookform/resolvers/zod";
2 | import { useForm, type UseFormProps } from "react-hook-form";
3 | import { type ZodType } from "zod";
4 |
5 | export function useZodForm(
6 | props: Omit, "resolver"> & {
7 | schema: TSchema;
8 | },
9 | ) {
10 | const form = useForm({
11 | ...props,
12 | resolver: zodResolver(props.schema, undefined),
13 | });
14 |
15 | return form;
16 | }
17 |
--------------------------------------------------------------------------------
/src/pages/api/trpc/[trpc].ts:
--------------------------------------------------------------------------------
1 | import { createNextApiHandler } from "@trpc/server/adapters/next";
2 |
3 | import { env } from "~/env.mjs";
4 | import { appRouter } from "~/server/api/root";
5 | import { createTRPCContext } from "~/server/api/trpc";
6 |
7 | // export API handler
8 | export default createNextApiHandler({
9 | router: appRouter,
10 | createContext: createTRPCContext,
11 | onError:
12 | env.NODE_ENV === "development"
13 | ? ({ path, error }) => {
14 | console.error(
15 | `❌ tRPC failed on ${path ?? ""}: ${error.message}`,
16 | );
17 | }
18 | : undefined,
19 | });
20 |
--------------------------------------------------------------------------------
/playwright.config.ts:
--------------------------------------------------------------------------------
1 | import { devices, type PlaywrightTestConfig } from "@playwright/test";
2 |
3 | const baseUrl = process.env.PLAYWRIGHT_TEST_BASE_URL || "http://localhost:3000";
4 |
5 | const opts = {
6 | headless: !!process.env.CI || !!process.env.PLAYWRIGHT_HEADLESS,
7 | // collectCoverage: !!process.env.PLAYWRIGHT_HEADLESS
8 | };
9 |
10 | const config: PlaywrightTestConfig = {
11 | testDir: "./e2e",
12 | globalSetup: "./e2e/setup/global.ts",
13 | use: {
14 | ...devices["Desktop Chrome"],
15 | storageState: "./e2e/setup/storage-state.json",
16 | baseURL: baseUrl,
17 | headless: opts.headless,
18 | },
19 | };
20 |
21 | export default config;
22 |
--------------------------------------------------------------------------------
/src/ui/label.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as React from "react";
4 | import * as LabelPrimitive from "@radix-ui/react-label";
5 |
6 | import { cn } from "~/utils/cn";
7 |
8 | const Label = React.forwardRef<
9 | React.ElementRef,
10 | React.ComponentPropsWithoutRef
11 | >(({ className, ...props }, ref) => (
12 |
20 | ));
21 | Label.displayName = LabelPrimitive.Root.displayName;
22 |
23 | export { Label };
24 |
--------------------------------------------------------------------------------
/src/server/api/root.ts:
--------------------------------------------------------------------------------
1 | import { z } from "zod";
2 |
3 | import { postRouter } from "./routers/post";
4 | import { createTRPCRouter, publicProcedure } from "./trpc";
5 |
6 | /**
7 | * This is the primary router for your server.
8 | *
9 | * All routers added in /api/routers should be manually added here
10 | */
11 | export const appRouter = createTRPCRouter({
12 | hello: publicProcedure
13 | .input(z.object({ text: z.string().nullish() }))
14 | .query(({ input }) => {
15 | return {
16 | greeting: `Hello from tRPC, ${input.text ?? "Anonymous"}`,
17 | };
18 | }),
19 | post: postRouter,
20 | });
21 |
22 | // export type definition of API
23 | export type AppRouter = typeof appRouter;
24 |
--------------------------------------------------------------------------------
/.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 | # database
12 | /prisma/db.sqlite
13 | /prisma/db.sqlite-journal
14 | /prisma/data
15 |
16 | # next.js
17 | /.next/
18 | /out/
19 | next-env.d.ts
20 |
21 | # production
22 | /build
23 |
24 | # misc
25 | .DS_Store
26 | *.pem
27 |
28 | # debug
29 | npm-debug.log*
30 | yarn-debug.log*
31 | yarn-error.log*
32 | .pnpm-debug.log*
33 |
34 | # local env files
35 | # do not commit any .env files to git, except for the .env.example file. https://create.t3.gg/en/usage/env-variables#using-environment-variables
36 | .env
37 | .env*.local
38 |
39 | # vercel
40 | .vercel
41 |
42 | # typescript
43 | *.tsbuildinfo
44 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es2017",
4 | "lib": ["dom", "dom.iterable", "esnext"],
5 | "allowJs": true,
6 | "checkJs": true,
7 | "skipLibCheck": true,
8 | "strict": true,
9 | "forceConsistentCasingInFileNames": true,
10 | "noEmit": true,
11 | "esModuleInterop": true,
12 | "module": "esnext",
13 | "moduleResolution": "node",
14 | "resolveJsonModule": true,
15 | "isolatedModules": true,
16 | "jsx": "preserve",
17 | "incremental": true,
18 | "noUncheckedIndexedAccess": true,
19 | "baseUrl": ".",
20 | "paths": {
21 | "~/*": ["./src/*"]
22 | }
23 | },
24 | "include": [
25 | "next-env.d.ts",
26 | "**/*.ts",
27 | "**/*.tsx",
28 | "**/*.cjs",
29 | "**/*.mjs",
30 | ".eslintrc.cjs"
31 | ],
32 | "exclude": ["node_modules"]
33 | }
34 |
--------------------------------------------------------------------------------
/next.config.mjs:
--------------------------------------------------------------------------------
1 | // @ts-check
2 |
3 | /**
4 | * Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation.
5 | * This is especially useful for Docker builds.
6 | */
7 | !process.env.SKIP_ENV_VALIDATION && (await import("./src/env.mjs"));
8 |
9 | /** @type {import("next").NextConfig} */
10 | const config = {
11 | reactStrictMode: true,
12 |
13 | /**
14 | * If you have the "experimental: { appDir: true }" setting enabled, then you
15 | * must comment the below `i18n` config out.
16 | *
17 | * @see https://github.com/vercel/next.js/issues/41980
18 | */
19 | i18n: {
20 | locales: ["en"],
21 | defaultLocale: "en",
22 | },
23 |
24 | // We run these separately in CI, so we can skip them here.
25 | eslint: { ignoreDuringBuilds: true },
26 | typescript: { ignoreBuildErrors: true },
27 | };
28 | export default config;
29 |
--------------------------------------------------------------------------------
/src/pages/_app.tsx:
--------------------------------------------------------------------------------
1 | import { type AppType } from "next/app";
2 | import { Inter as FontSans } from "next/font/google";
3 | import { type Session } from "next-auth";
4 | import { SessionProvider } from "next-auth/react";
5 |
6 | import "~/styles/globals.css";
7 | import { api } from "~/utils/api";
8 |
9 | const fontSans = FontSans({
10 | subsets: ["latin"],
11 | variable: "--font-sans",
12 | });
13 |
14 | const MyApp: AppType<{ session: Session | null }> = ({
15 | Component,
16 | pageProps: { session, ...pageProps },
17 | }) => {
18 | return (
19 | <>
20 |
27 |
28 |
29 |
30 | >
31 | );
32 | };
33 |
34 | export default api.withTRPC(MyApp);
35 |
--------------------------------------------------------------------------------
/src/ui/input.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as React from "react";
4 |
5 | import { cn } from "~/utils/cn";
6 |
7 | // eslint-disable-next-line @typescript-eslint/no-empty-interface
8 | export interface InputProps
9 | extends React.InputHTMLAttributes {}
10 |
11 | const Input = React.forwardRef(
12 | ({ className, ...props }, ref) => {
13 | return (
14 |
22 | );
23 | },
24 | );
25 | Input.displayName = "Input";
26 |
27 | export { Input };
28 |
--------------------------------------------------------------------------------
/.eslintrc.cjs:
--------------------------------------------------------------------------------
1 | /** @type{import("eslint").Linter.Config} */
2 | module.exports = {
3 | overrides: [
4 | {
5 | extends: [
6 | "plugin:@typescript-eslint/recommended-requiring-type-checking",
7 | ],
8 | files: ["*.ts", "*.tsx"],
9 | excludedFiles: ["**/*.test.ts", "**/*.test.tsx"],
10 | parserOptions: {
11 | project: "tsconfig.json",
12 | },
13 | },
14 | ],
15 | parser: "@typescript-eslint/parser",
16 | parserOptions: {
17 | project: "./tsconfig.json",
18 | },
19 | plugins: ["@typescript-eslint"],
20 | extends: ["next/core-web-vitals", "plugin:@typescript-eslint/recommended"],
21 | rules: {
22 | "@typescript-eslint/consistent-type-imports": "warn",
23 | "@typescript-eslint/no-misused-promises": [
24 | 2,
25 | {
26 | checksVoidReturn: {
27 | // Allow promises to be used as attributes such as `onSubmit`
28 | attributes: false,
29 | },
30 | },
31 | ],
32 | },
33 | };
34 |
--------------------------------------------------------------------------------
/src/ui/text-area.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as React from "react";
4 |
5 | import { cn } from "~/utils/cn";
6 |
7 | // eslint-disable-next-line @typescript-eslint/no-empty-interface
8 | export interface TextareaProps
9 | extends React.TextareaHTMLAttributes {}
10 |
11 | const Textarea = React.forwardRef(
12 | ({ className, ...props }, ref) => {
13 | return (
14 |
22 | );
23 | },
24 | );
25 | Textarea.displayName = "Textarea";
26 |
27 | export { Textarea };
28 |
--------------------------------------------------------------------------------
/tailwind.config.ts:
--------------------------------------------------------------------------------
1 | import type { Config } from "tailwindcss";
2 | // eslint-disable-next-line @typescript-eslint/no-var-requires
3 | import { fontFamily } from "tailwindcss/defaultTheme";
4 |
5 | export default {
6 | darkMode: ["class", '[data-theme="dark"]'],
7 | content: ["src/**/*.{ts,tsx}"],
8 | theme: {
9 | extend: {
10 | fontFamily: {
11 | sans: ["var(--font-sans)", ...fontFamily.sans],
12 | },
13 | keyframes: {
14 | "accordion-down": {
15 | from: { height: "0" },
16 | to: { height: "var(--radix-accordion-content-height)" },
17 | },
18 | "accordion-up": {
19 | from: { height: "var(--radix-accordion-content-height)" },
20 | to: { height: "0" },
21 | },
22 | },
23 | animation: {
24 | "accordion-down": "accordion-down 0.2s ease-out",
25 | "accordion-up": "accordion-up 0.2s ease-out",
26 | },
27 | },
28 | },
29 | plugins: [require("tailwindcss-animate")],
30 | } satisfies Config;
31 |
--------------------------------------------------------------------------------
/.env.example:
--------------------------------------------------------------------------------
1 | # Since the ".env" file is gitignored, you can use the ".env.example" file to
2 | # build a new ".env" file when you clone the repo. Keep this file up-to-date
3 | # when you add new variables to `.env`.
4 |
5 | # This file will be committed to version control, so make sure not to have any
6 | # secrets in it. If you are cloning this repo, create a copy of this file named
7 | # ".env" and populate it with your secrets.
8 |
9 | # When adding additional environment variables, the schema in "/env/schema.mjs"
10 | # should be updated accordingly.
11 |
12 | # Prisma
13 | # https://www.prisma.io/docs/reference/database-reference/connection-urls#env
14 | DATABASE_URL="postgresql://postgres:@localhost:5432/db?schema=public"
15 |
16 | # Next Auth
17 | # You can generate a new secret on the command line with:
18 | # openssl rand -base64 32
19 | # https://next-auth.js.org/configuration/options#secret
20 | # NEXTAUTH_SECRET=""
21 | NEXTAUTH_URL="http://localhost:3000"
22 |
23 | # Next Auth Discord Provider
24 | DISCORD_CLIENT_ID=""
25 | DISCORD_CLIENT_SECRET=""
26 |
--------------------------------------------------------------------------------
/prettier.config.cjs:
--------------------------------------------------------------------------------
1 | /** @typedef {import("@ianvs/prettier-plugin-sort-imports").PluginConfig} SortImportsConfig*/
2 | /** @typedef {import("prettier").Config} PrettierConfig*/
3 | /** @typedef {{ tailwindConfig: string }} TailwindConfig*/
4 |
5 | /** @type { PrettierConfig | SortImportsConfig | TailwindConfig } */
6 | const config = {
7 | arrowParens: "always",
8 | printWidth: 80,
9 | singleQuote: false,
10 | jsxSingleQuote: false,
11 | semi: true,
12 | trailingComma: "all",
13 | tabWidth: 2,
14 | plugins: [
15 | "@ianvs/prettier-plugin-sort-imports",
16 | /**
17 | * If you're adding more plugins, keep in mind
18 | * that the Tailwind plugin must come last!
19 | */
20 | "prettier-plugin-tailwindcss",
21 | ],
22 | tailwindConfig: "./tailwind.config.ts",
23 | importOrder: [
24 | "^(react/(.*)$)|^(react$)",
25 | "^(next/(.*)$)|^(next$)",
26 | "",
27 | "",
28 | "^~/utils/(.*)$",
29 | "^~/components/(.*)$",
30 | "^~/styles/(.*)$",
31 | "^~/(.*)$",
32 | "^[./]",
33 | ],
34 | importOrderSeparation: false,
35 | importOrderSortSpecifiers: true,
36 | importOrderBuiltinModulesToTop: true,
37 | importOrderParserPlugins: ["typescript", "jsx", "decorators-legacy"],
38 | importOrderMergeDuplicateImports: true,
39 | importOrderCombineTypeAndValueImports: true,
40 | };
41 |
42 | module.exports = config;
43 |
--------------------------------------------------------------------------------
/src/server/api/routers/post.ts:
--------------------------------------------------------------------------------
1 | import { z } from "zod";
2 |
3 | import { postCreateSchema } from "~/pages/posts";
4 | import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
5 |
6 | export const postRouter = createTRPCRouter({
7 | byId: publicProcedure
8 | .input(z.object({ id: z.string() }))
9 | .query(({ ctx, input }) => {
10 | return ctx.prisma.post.findUnique({
11 | where: {
12 | id: input.id,
13 | },
14 | });
15 | }),
16 |
17 | getAll: publicProcedure.query(({ ctx }) => {
18 | return ctx.prisma.post.findMany({
19 | include: {
20 | author: {
21 | select: {
22 | name: true,
23 | image: true,
24 | },
25 | },
26 | },
27 | });
28 | }),
29 |
30 | create: protectedProcedure
31 | .input(postCreateSchema)
32 | .mutation(({ ctx, input }) => {
33 | return ctx.prisma.post.create({
34 | data: {
35 | title: input.title,
36 | body: input.body,
37 | category: input.category,
38 | author: { connect: { id: ctx.session.user.id } },
39 | },
40 | });
41 | }),
42 |
43 | delete: protectedProcedure
44 | .input(z.object({ id: z.string() }))
45 | .mutation(({ ctx, input }) => {
46 | return ctx.prisma.post.delete({
47 | where: {
48 | id: input.id,
49 | },
50 | });
51 | }),
52 | });
53 |
--------------------------------------------------------------------------------
/test/trpc/post-router.test.ts:
--------------------------------------------------------------------------------
1 | import { expect, it } from "vitest";
2 |
3 | import { type RouterInputs } from "~/utils/api";
4 | import { appRouter } from "~/server/api/root";
5 | import { createInnerTRPCContext } from "~/server/api/trpc";
6 | import { prisma } from "~/server/db";
7 |
8 | it("unauthed user should not be possible to create a post", async () => {
9 | const ctx = await createInnerTRPCContext({ session: null });
10 | const caller = appRouter.createCaller(ctx);
11 |
12 | const input: RouterInputs["post"]["create"] = {
13 | title: "hello test",
14 | body: "hello test with a long input",
15 | category: "ENGINEERING",
16 | };
17 |
18 | await expect(caller.post.create(input)).rejects.toThrowError();
19 | });
20 |
21 | it("post should be get-able after created", async () => {
22 | const user = await prisma.user.upsert({
23 | where: { email: "test@test.com" },
24 | create: { name: "test", email: "test@test.com", image: "" },
25 | update: {},
26 | });
27 | const ctx = await createInnerTRPCContext({
28 | session: {
29 | user,
30 | expires: "1",
31 | },
32 | });
33 | const caller = appRouter.createCaller(ctx);
34 |
35 | const input: RouterInputs["post"]["create"] = {
36 | title: "hello test",
37 | body: "hello test with a long input",
38 | category: "ENGINEERING",
39 | };
40 |
41 | const post = await caller.post.create(input);
42 | const byId = await caller.post.byId({ id: post.id });
43 |
44 | expect(byId).toMatchObject(input);
45 | });
46 |
--------------------------------------------------------------------------------
/src/ui/avatar.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as React from "react";
4 | import * as AvatarPrimitive from "@radix-ui/react-avatar";
5 |
6 | import { cn } from "~/utils/cn";
7 |
8 | const Avatar = React.forwardRef<
9 | React.ElementRef,
10 | React.ComponentPropsWithoutRef
11 | >(({ className, ...props }, ref) => (
12 |
20 | ));
21 | Avatar.displayName = AvatarPrimitive.Root.displayName;
22 |
23 | const AvatarImage = React.forwardRef<
24 | React.ElementRef,
25 | React.ComponentPropsWithoutRef
26 | >(({ className, ...props }, ref) => (
27 |
32 | ));
33 | AvatarImage.displayName = AvatarPrimitive.Image.displayName;
34 |
35 | const AvatarFallback = React.forwardRef<
36 | React.ElementRef,
37 | React.ComponentPropsWithoutRef
38 | >(({ className, ...props }, ref) => (
39 |
47 | ));
48 | AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName;
49 |
50 | export { Avatar, AvatarImage, AvatarFallback };
51 |
--------------------------------------------------------------------------------
/e2e/setup/global.ts:
--------------------------------------------------------------------------------
1 | import path from "node:path";
2 | import { chromium, type BrowserContext } from "@playwright/test";
3 |
4 | import { prisma } from "~/server/db";
5 |
6 | type Cookie = Parameters[0][0];
7 | const testCookie: Cookie = {
8 | name: "next-auth.session-token",
9 | value: "d52f0c50-b8e3-4326-b48c-4d4a66fdeb64", // some random id
10 | domain: "localhost",
11 | path: "/",
12 | expires: -1, // expired => forces browser to refresh cookie on test run
13 | httpOnly: true,
14 | secure: false,
15 | sameSite: "Lax",
16 | };
17 |
18 | export default async function globalSetup() {
19 | const now = new Date();
20 |
21 | await prisma.user.upsert({
22 | where: {
23 | email: "octocat@github.com",
24 | },
25 | create: {
26 | name: "Octocat",
27 | email: "octocat@github.com",
28 | image: "https://github.com/octocat.png",
29 | sessions: {
30 | create: {
31 | // create a session in db that hasn't expired yet, with the same id as the cookie
32 | expires: new Date(now.getFullYear(), now.getMonth() + 1, 0),
33 | sessionToken: testCookie.value,
34 | },
35 | },
36 | accounts: {
37 | // some random mocked discord account
38 | create: {
39 | type: "oauth",
40 | provider: "discord",
41 | providerAccountId: "123456789",
42 | access_token: "ggg_zZl1pWIvKkf3UDynZ09zLvuyZsm1yC0YoRPt",
43 | token_type: "bearer",
44 | scope: "email identify",
45 | },
46 | },
47 | },
48 | update: {},
49 | });
50 |
51 | const storageState = path.resolve(__dirname, "storage-state.json");
52 | const browser = await chromium.launch();
53 | const context = await browser.newContext({ storageState });
54 | await context.addCookies([testCookie]);
55 | await context.storageState({ path: storageState });
56 | await browser.close();
57 | }
58 |
--------------------------------------------------------------------------------
/e2e/smoke.test.ts:
--------------------------------------------------------------------------------
1 | import { expect, test, type Page } from "@playwright/test";
2 |
3 | async function createPost(
4 | post: { title: string; body: string; category: string },
5 | page: Page,
6 | ) {
7 | // fill title, body and category and click submit
8 | await page.getByRole("textbox", { name: "Title" }).type(post.title);
9 | await page.getByRole("textbox", { name: "Body" }).type(post.body);
10 | await page.getByText("Select a category").click();
11 | await page.getByRole("option", { name: post.category }).click();
12 | await page.getByRole("button").getByText("Post").click();
13 | }
14 |
15 | test("write a post", async ({ page }) => {
16 | await page.goto("/");
17 |
18 | // go to posts page, fill title, body and category and click submit
19 | await page.getByRole("link", { name: "Posts" }).click();
20 | await createPost(
21 | { title: "Testy", body: "short post", category: "DESIGN" },
22 | page,
23 | );
24 |
25 | // initial post should fail due to body being too short
26 | await expect(page.getByText("must contain at least 20")).toBeVisible();
27 | await page.getByRole("textbox", { name: "Body" }).type(" with looong body");
28 | await page.getByRole("button").getByText("Post").click();
29 |
30 | // post should be visible on the page
31 | await expect(page.getByText("testy")).toBeVisible();
32 | await expect(page.getByText("short post with looong body")).toBeVisible();
33 | });
34 |
35 | test("delete a post", async ({ page }) => {
36 | await page.goto("/posts");
37 |
38 | await createPost(
39 | {
40 | title: "Testy Posty",
41 | body: "This will be a shortlived post",
42 | category: "ENGINEERING",
43 | },
44 | page,
45 | );
46 |
47 | // post should be visible on the page
48 | await expect(page.getByText("Testy Posty")).toBeVisible();
49 | await expect(page.getByText("This will be a shortlived post")).toBeVisible();
50 |
51 | // delete the post
52 | await page.getByTestId(`delete-post-Testy Posty`).click();
53 | await page.getByRole("button").getByText("Delete").click();
54 |
55 | // post should be gone
56 | await expect(page.getByText("Testy Posty")).not.toBeVisible();
57 | });
58 |
--------------------------------------------------------------------------------
/prisma/schema.prisma:
--------------------------------------------------------------------------------
1 | // This is your Prisma schema file,
2 | // learn more about it in the docs: https://pris.ly/d/prisma-schema
3 |
4 | generator client {
5 | provider = "prisma-client-js"
6 | }
7 |
8 | datasource db {
9 | provider = "postgresql"
10 | // NOTE: When using postgresql, mysql or sqlserver, uncomment the @db.Text annotations in model Account below
11 | // Further reading:
12 | // https://next-auth.js.org/adapters/prisma#create-the-prisma-schema
13 | // https://www.prisma.io/docs/reference/api-reference/prisma-schema-reference#string
14 | url = env("DATABASE_URL")
15 | }
16 |
17 | enum PostCategory {
18 | BUSINESS
19 | DESIGN
20 | ENGINEERING
21 | }
22 |
23 | model Post {
24 | id String @id @default(cuid())
25 | title String
26 | body String
27 | category PostCategory
28 |
29 | authorId String
30 | author User @relation(fields: [authorId], references: [id])
31 | createdAt DateTime @default(now())
32 | updatedAt DateTime @updatedAt
33 | }
34 |
35 | model Account {
36 | id String @id @default(cuid())
37 | userId String
38 | type String
39 | provider String
40 | providerAccountId String
41 | refresh_token String? @db.Text
42 | access_token String? @db.Text
43 | expires_at Int?
44 | token_type String?
45 | scope String?
46 | id_token String? @db.Text
47 | session_state String?
48 | user User @relation(fields: [userId], references: [id], onDelete: Cascade)
49 |
50 | @@unique([provider, providerAccountId])
51 | }
52 |
53 | model Session {
54 | id String @id @default(cuid())
55 | sessionToken String @unique
56 | userId String
57 | expires DateTime
58 | user User @relation(fields: [userId], references: [id], onDelete: Cascade)
59 | }
60 |
61 | model User {
62 | id String @id @default(cuid())
63 | name String
64 | email String @unique
65 | emailVerified DateTime?
66 | image String
67 | accounts Account[]
68 | sessions Session[]
69 | Post Post[]
70 | }
71 |
--------------------------------------------------------------------------------
/src/utils/api.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * This is the client-side entrypoint for your tRPC API.
3 | * It is used to create the `api` object which contains the Next.js
4 | * App-wrapper, as well as your type-safe React Query hooks.
5 | *
6 | * We also create a few inference helpers for input and output types
7 | */
8 | import { httpBatchLink, loggerLink } from "@trpc/client";
9 | import { createTRPCNext } from "@trpc/next";
10 | import { type inferRouterInputs, type inferRouterOutputs } from "@trpc/server";
11 | import superjson from "superjson";
12 |
13 | import { type AppRouter } from "~/server/api/root";
14 |
15 | const getBaseUrl = () => {
16 | if (typeof window !== "undefined") return ""; // browser should use relative url
17 | if (process.env.VERCEL_URL) return `https://${process.env.VERCEL_URL}`; // SSR should use vercel url
18 | return `http://localhost:${process.env.PORT ?? 3000}`; // dev SSR should use localhost
19 | };
20 |
21 | /** A set of type-safe react-query hooks for your tRPC API. */
22 | export const api = createTRPCNext({
23 | config() {
24 | return {
25 | /**
26 | * Transformer used for data de-serialization from the server.
27 | *
28 | * @see https://trpc.io/docs/data-transformers
29 | **/
30 | transformer: superjson,
31 |
32 | /**
33 | * Links used to determine request flow from client to server.
34 | *
35 | * @see https://trpc.io/docs/links
36 | * */
37 | links: [
38 | loggerLink({
39 | enabled: (opts) =>
40 | process.env.NODE_ENV === "development" ||
41 | (opts.direction === "down" && opts.result instanceof Error),
42 | }),
43 | httpBatchLink({
44 | url: `${getBaseUrl()}/api/trpc`,
45 | }),
46 | ],
47 | };
48 | },
49 | /**
50 | * Whether tRPC should await queries when server rendering pages.
51 | *
52 | * @see https://trpc.io/docs/nextjs#ssr-boolean-default-false
53 | */
54 | ssr: false,
55 | });
56 |
57 | /**
58 | * Inference helper for inputs.
59 | *
60 | * @example type HelloInput = RouterInputs['example']['hello']
61 | **/
62 | export type RouterInputs = inferRouterInputs;
63 |
64 | /**
65 | * Inference helper for outputs.
66 | *
67 | * @example type HelloOutput = RouterOutputs['example']['hello']
68 | **/
69 | export type RouterOutputs = inferRouterOutputs;
70 |
--------------------------------------------------------------------------------
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
1 | name: CI
2 |
3 | on:
4 | push:
5 | branches:
6 | - main
7 | pull_request:
8 | branches:
9 | - main
10 |
11 | jobs:
12 | build-test:
13 | runs-on: ubuntu-latest
14 | services:
15 | postgres:
16 | image: postgres
17 | env:
18 | POSTGRES_USER: postgres
19 | POSTGRES_HOST_AUTH_METHOD: trust
20 | options: >-
21 | --health-cmd pg_isready
22 | --health-interval 10s
23 | --health-timeout 5s
24 | --health-retries 5
25 | ports:
26 | - 5432:5432
27 |
28 | steps:
29 | - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4
30 |
31 | - name: Install pnpm
32 | uses: pnpm/action-setup@v2.4.0
33 |
34 | - uses: actions/setup-node@v4
35 | with:
36 | node-version: 18
37 | cache: "pnpm"
38 |
39 | - name: Install dependencies and playwright binaries
40 | run: pnpm install && pnpm playwright install chromium
41 |
42 | - name: Setup environment variables
43 | run: |
44 | cp .env.example .env
45 | echo "NEXTAUTH_SECRET=supersecret" >> .env
46 |
47 | - name: Push schema to database
48 | run: pnpx prisma db push
49 |
50 | - name: Build application
51 | run: pnpm build
52 |
53 | - name: Run all tests
54 | run: pnpm test
55 |
56 | lint:
57 | runs-on: ubuntu-latest
58 | steps:
59 | - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4
60 |
61 | - name: Install pnpm
62 | uses: pnpm/action-setup@v2.4.0
63 |
64 | - uses: actions/setup-node@v4
65 | with:
66 | node-version: 18
67 | cache: "pnpm"
68 |
69 | - name: Install dependencies
70 | run: pnpm install
71 |
72 | - name: Run linter
73 | run: |
74 | pnpm lint
75 | pnpm format:check
76 | env:
77 | SKIP_ENV_VALIDATION: true
78 |
79 | typecheck:
80 | runs-on: ubuntu-latest
81 | steps:
82 | - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4
83 |
84 | - name: Install pnpm
85 | uses: pnpm/action-setup@v2.4.0
86 |
87 | - uses: actions/setup-node@v4
88 | with:
89 | node-version: 18
90 | cache: "pnpm"
91 |
92 | - name: Install dependencies
93 | run: pnpm install
94 |
95 | - name: Run typecheck
96 | run: pnpm ts:check
97 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "t3-complete",
3 | "version": "0.1.0",
4 | "private": true,
5 | "packageManager": "pnpm@8.10.0",
6 | "scripts": {
7 | "build": "next build",
8 | "dev": "next dev",
9 | "postinstall": "prisma generate",
10 | "lint": "next lint",
11 | "format": "prettier --write .",
12 | "format:check": "prettier --check .",
13 | "start": "next start",
14 | "test": "pnpm test:unit && pnpm test:e2e",
15 | "test:unit": "vitest run",
16 | "test:unit:watch": "vitest",
17 | "test:e2e": "start-server-and-test dev 3000 \"NODE_ENV=test playwright test\"",
18 | "ts:check": "tsc --noEmit"
19 | },
20 | "dependencies": {
21 | "@hookform/resolvers": "^3.0.0",
22 | "@next-auth/prisma-adapter": "^1.0.5",
23 | "@prisma/client": "^5.0.0",
24 | "@radix-ui/react-avatar": "^1.0.2",
25 | "@radix-ui/react-dialog": "^1.0.3",
26 | "@radix-ui/react-dropdown-menu": "^2.0.4",
27 | "@radix-ui/react-label": "^2.0.1",
28 | "@radix-ui/react-select": "^2.0.0",
29 | "@tanstack/react-query": "^4.28.0",
30 | "@trpc/client": "^10.18.0",
31 | "@trpc/next": "^10.18.0",
32 | "@trpc/react-query": "^10.18.0",
33 | "@trpc/server": "^10.18.0",
34 | "class-variance-authority": "^0.7.0",
35 | "clsx": "^2.0.0",
36 | "lucide-react": "^0.290.0",
37 | "next": "^14.0.0",
38 | "next-auth": "^4.21.1",
39 | "react": "18.2.0",
40 | "react-dom": "18.2.0",
41 | "react-hook-form": "^7.43.9",
42 | "superjson": "2.2.0",
43 | "tailwind-merge": "^1.11.0",
44 | "tailwindcss-animate": "^1.0.5",
45 | "zod": "^3.21.4"
46 | },
47 | "devDependencies": {
48 | "@ianvs/prettier-plugin-sort-imports": "^3.7.2",
49 | "@playwright/test": "^1.32.2",
50 | "@types/eslint": "^8.37.0",
51 | "@types/node": "^20.0.0",
52 | "@types/prettier": "^2.7.2",
53 | "@types/react": "^18.0.33",
54 | "@types/react-dom": "^18.0.11",
55 | "@typescript-eslint/eslint-plugin": "^6.0.0",
56 | "@typescript-eslint/parser": "^6.0.0",
57 | "autoprefixer": "^10.4.14",
58 | "eslint": "^8.37.0",
59 | "eslint-config-next": "^14.0.0",
60 | "postcss": "^8.4.21",
61 | "prettier": "^2.8.7",
62 | "prettier-plugin-tailwindcss": "^0.4.0",
63 | "prisma": "^5.0.0",
64 | "start-server-and-test": "^2.0.0",
65 | "tailwindcss": "^3.3.1",
66 | "typescript": "^5.0.3",
67 | "vite": "^4.0.0",
68 | "vitest": "^0.34.0"
69 | },
70 | "ct3aMetadata": {
71 | "initVersion": "7.5.1"
72 | }
73 | }
74 |
--------------------------------------------------------------------------------
/src/ui/button.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as React from "react";
4 | import { cva, type VariantProps } from "class-variance-authority";
5 |
6 | import { cn } from "~/utils/cn";
7 |
8 | const buttonVariants = cva(
9 | "inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-slate-400 focus:ring-offset-2 dark:hover:bg-slate-800 dark:hover:text-slate-100 disabled:opacity-50 dark:focus:ring-slate-400 disabled:pointer-events-none dark:focus:ring-offset-slate-900 data-[state=open]:bg-slate-100 dark:data-[state=open]:bg-slate-800",
10 | {
11 | variants: {
12 | variant: {
13 | primary:
14 | "bg-blue-500 hover:bg-sky-600 dark:bg-blue-600 text-slate-300 dark:text-slate-900 dark:hover:bg-blue-700",
15 | destructive:
16 | "bg-red-400 text-white hover:bg-red-500 dark:bg-red-400 dark:text-slate-900 dark:hover:bg-red-500",
17 | default:
18 | "bg-slate-900 text-white hover:bg-slate-700 dark:bg-slate-50 dark:text-slate-900",
19 | outline:
20 | "bg-transparent border border-slate-200 hover:bg-slate-100 dark:border-slate-700 dark:text-slate-100",
21 | subtle:
22 | "bg-slate-100 text-slate-900 hover:bg-slate-200 dark:bg-slate-700 dark:text-slate-100",
23 | ghost:
24 | "bg-transparent hover:bg-slate-100 dark:hover:bg-slate-800 dark:text-slate-100 dark:hover:text-slate-100 data-[state=open]:bg-transparent dark:data-[state=open]:bg-transparent",
25 | link: "bg-transparent underline-offset-4 hover:underline text-slate-900 dark:text-slate-100 hover:bg-transparent dark:hover:bg-transparent",
26 | },
27 | size: {
28 | default: "h-10 py-2 px-4",
29 | xs: "h-8 px-1 rounded-md",
30 | sm: "h-9 px-2 rounded-md",
31 | lg: "h-11 px-8 rounded-md",
32 | },
33 | },
34 | defaultVariants: {
35 | variant: "default",
36 | size: "default",
37 | },
38 | },
39 | );
40 |
41 | export interface ButtonProps
42 | extends React.ButtonHTMLAttributes,
43 | VariantProps {}
44 |
45 | const Button = React.forwardRef(
46 | ({ className, variant, size, ...props }, ref) => {
47 | return (
48 |
53 | );
54 | },
55 | );
56 | Button.displayName = "Button";
57 |
58 | export { Button, buttonVariants };
59 |
--------------------------------------------------------------------------------
/src/server/auth.ts:
--------------------------------------------------------------------------------
1 | import type { GetServerSidePropsContext } from "next";
2 | import { PrismaAdapter } from "@next-auth/prisma-adapter";
3 | import {
4 | getServerSession,
5 | type DefaultSession,
6 | type NextAuthOptions,
7 | } from "next-auth";
8 | import DiscordProvider from "next-auth/providers/discord";
9 |
10 | import { env } from "~/env.mjs";
11 | import { prisma } from "~/server/db";
12 |
13 | /**
14 | * Module augmentation for `next-auth` types.
15 | * Allows us to add custom properties to the `session` object and keep type
16 | * safety.
17 | *
18 | * @see https://next-auth.js.org/getting-started/typescript#module-augmentation
19 | **/
20 | declare module "next-auth" {
21 | interface Session extends DefaultSession {
22 | user: {
23 | id: string;
24 | // ...other properties
25 | // role: UserRole;
26 | } & DefaultSession["user"];
27 | }
28 |
29 | // interface User {
30 | // // ...other properties
31 | // // role: UserRole;
32 | // }
33 | }
34 |
35 | /**
36 | * Options for NextAuth.js used to configure adapters, providers, callbacks,
37 | * etc.
38 | *
39 | * @see https://next-auth.js.org/configuration/options
40 | **/
41 |
42 | export const authOptions: NextAuthOptions = {
43 | callbacks: {
44 | session({ session, user }) {
45 | if (session.user) {
46 | session.user.id = user.id;
47 | // session.user.role = user.role; <-- put other properties on the session here
48 | }
49 |
50 | return session;
51 | },
52 | },
53 | adapter: PrismaAdapter(prisma),
54 | providers: [
55 | DiscordProvider({
56 | clientId: env.DISCORD_CLIENT_ID,
57 | clientSecret: env.DISCORD_CLIENT_SECRET,
58 | }),
59 | /**
60 | * ...add more providers here
61 | *
62 | * Most other providers require a bit more work than the Discord provider.
63 | * For example, the GitHub provider requires you to add the
64 | * `refresh_token_expires_in` field to the Account model. Refer to the
65 | * NextAuth.js docs for the provider you want to use. Example:
66 | * @see https://next-auth.js.org/providers/github
67 | **/
68 | ],
69 | };
70 |
71 | /**
72 | * Wrapper for `getServerSession` so that you don't need to import the
73 | * `authOptions` in every file.
74 | *
75 | * @see https://next-auth.js.org/configuration/nextjs
76 | **/
77 | export const getServerAuthSession = (ctx: {
78 | req: GetServerSidePropsContext["req"];
79 | res: GetServerSidePropsContext["res"];
80 | }) => {
81 | return getServerSession(ctx.req, ctx.res, authOptions);
82 | };
83 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Create T3 App (extended)
2 |
3 | This is an extended version of the [T3 Stack](https://create.t3.gg/) project bootstrapped with `create-t3-app` that includes:
4 |
5 | - UI Components using [shadcn/ui](https://ui.shadcn.com) - which is built on top of [Radix UI](https://radix-ui.com) & [Tailwind CSS](https://tailwindcss.com)
6 | - Full-Stack CRUD example with tRPC mutations (protected routes) using the UI components together with [react-hook-form](https://react-hook-form.com).
7 | - E2E Testing using [Playwright](https://playwright.dev)
8 | - Integration tests using [Vitest](https://vitest.dev).
9 | - Docker Compose setup for local database
10 | - [`@next/font`] for optimized fonts
11 |
12 | [Try it out now!](https://t3-complete.vercel.app)
13 |
14 | ## Getting Started
15 |
16 | 1. Install deps
17 |
18 | ```bash
19 | pnpm install
20 | ```
21 |
22 | 2. Start the db
23 |
24 | ```bash
25 | docker compose up -d
26 | ```
27 |
28 | 3. Update env and push the schema to the db
29 |
30 | ```bash
31 | cp .env.example .env
32 | pnpm prisma db push
33 | ```
34 |
35 | 4. Start the dev server
36 |
37 | ```bash
38 | pnpm dev
39 | ```
40 |
41 | 5. Run the tests
42 |
43 | ```bash
44 | pnpm test
45 | ```
46 |
47 | ---
48 |
49 | This is a [T3 Stack](https://create.t3.gg/) project bootstrapped with `create-t3-app`.
50 |
51 | ## What's next? How do I make an app with this?
52 |
53 | We try to keep this project as simple as possible, so you can start with just the scaffolding we set up for you, and add additional things later when they become necessary.
54 |
55 | If you are not familiar with the different technologies used in this project, please refer to the respective docs. If you still are in the wind, please join our [Discord](https://t3.gg/discord) and ask for help.
56 |
57 | - [Next.js](https://nextjs.org)
58 | - [NextAuth.js](https://next-auth.js.org)
59 | - [Prisma](https://prisma.io)
60 | - [Tailwind CSS](https://tailwindcss.com)
61 | - [tRPC](https://trpc.io)
62 |
63 | ## Learn More
64 |
65 | To learn more about the [T3 Stack](https://create.t3.gg/), take a look at the following resources:
66 |
67 | - [Documentation](https://create.t3.gg/)
68 | - [Learn the T3 Stack](https://create.t3.gg/en/faq#what-learning-resources-are-currently-available) — Check out these awesome tutorials
69 |
70 | You can check out the [create-t3-app GitHub repository](https://github.com/t3-oss/create-t3-app) — your feedback and contributions are welcome!
71 |
72 | ## How do I deploy this?
73 |
74 | Follow our deployment guides for [Vercel](https://create.t3.gg/en/deployment/vercel), [Netlify](https://create.t3.gg/en/deployment/netlify) and [Docker](https://create.t3.gg/en/deployment/docker) for more information.
75 |
--------------------------------------------------------------------------------
/src/pages/index.tsx:
--------------------------------------------------------------------------------
1 | import { type NextPage } from "next";
2 | import Head from "next/head";
3 | import Link from "next/link";
4 | import { Book } from "lucide-react";
5 | import { signIn, signOut, useSession } from "next-auth/react";
6 |
7 | import { api } from "~/utils/api";
8 |
9 | const Home: NextPage = () => {
10 | const { data: session } = useSession();
11 | const hello = api.hello.useQuery({ text: session?.user.name });
12 |
13 | return (
14 | <>
15 |
16 | Create T3 App
17 |
18 |
19 |
20 |
21 |
22 |
23 | Create T3 App
24 |
25 |
26 |
31 |
First Steps →
32 |
Just the basics
33 |
34 |
38 |
39 |
40 | Posts
41 |
42 |
43 |
44 |
45 |
46 |
47 | See what others post and submit your own
48 |
49 |
50 |
51 |
52 |
53 | {hello.data ? hello.data.greeting : "Loading tRPC query..."}
54 |
55 |
56 |
57 |
58 |
59 | >
60 | );
61 | };
62 |
63 | export default Home;
64 |
65 | const AuthShowcase: React.FC = () => {
66 | const { data: sessionData } = useSession();
67 |
68 | return (
69 |
70 |
76 |
77 | );
78 | };
79 |
--------------------------------------------------------------------------------
/src/env.mjs:
--------------------------------------------------------------------------------
1 | import { z } from "zod";
2 |
3 | /**
4 | * Specify your server-side environment variables schema here.
5 | * This way you can ensure the app isn't built with invalid env vars.
6 | */
7 | const server = z.object({
8 | DATABASE_URL: z.string().url(),
9 | NODE_ENV: z.enum(["development", "test", "production"]),
10 | NEXTAUTH_SECRET:
11 | process.env.NODE_ENV === "production"
12 | ? z.string().min(1)
13 | : z.string().min(1).optional(),
14 | NEXTAUTH_URL: z.preprocess(
15 | // This makes Vercel deployments not fail if you don't set NEXTAUTH_URL
16 | // Since NextAuth.js automatically uses the VERCEL_URL if present.
17 | (str) => process.env.VERCEL_URL ?? str,
18 | // VERCEL_URL doesn't include `https` so it cant be validated as a URL
19 | process.env.VERCEL ? z.string().min(1) : z.string().url(),
20 | ),
21 | // Add `.min(1) on ID and SECRET if you want to make sure they're not empty
22 | DISCORD_CLIENT_ID: z.string(),
23 | DISCORD_CLIENT_SECRET: z.string(),
24 | });
25 |
26 | /**
27 | * Specify your client-side environment variables schema here.
28 | * This way you can ensure the app isn't built with invalid env vars.
29 | * To expose them to the client, prefix them with `NEXT_PUBLIC_`.
30 | */
31 | const client = z.object({
32 | // NEXT_PUBLIC_CLIENTVAR: z.string().min(1),
33 | });
34 |
35 | /**
36 | * You can't destruct `process.env` as a regular object in the Next.js
37 | * edge runtimes (e.g. middlewares) or client-side so we need to destruct manually.
38 | * @type {Record | keyof z.infer, string | undefined>}
39 | */
40 | const processEnv = {
41 | DATABASE_URL: process.env.DATABASE_URL,
42 | NODE_ENV: process.env.NODE_ENV,
43 | NEXTAUTH_SECRET: process.env.NEXTAUTH_SECRET,
44 | NEXTAUTH_URL: process.env.NEXTAUTH_URL,
45 | DISCORD_CLIENT_ID: process.env.DISCORD_CLIENT_ID,
46 | DISCORD_CLIENT_SECRET: process.env.DISCORD_CLIENT_SECRET,
47 | // NEXT_PUBLIC_CLIENTVAR: process.env.NEXT_PUBLIC_CLIENTVAR,
48 | };
49 |
50 | // Don't touch the part below
51 | // --------------------------
52 |
53 | const merged = server.merge(client);
54 |
55 | /** @typedef {z.input} MergedInput */
56 | /** @typedef {z.infer} MergedOutput */
57 | /** @typedef {z.SafeParseReturnType} MergedSafeParseReturn */
58 |
59 | let env = /** @type {MergedOutput} */ (process.env);
60 |
61 | if (!!process.env.SKIP_ENV_VALIDATION == false) {
62 | const isServer = typeof window === "undefined";
63 |
64 | const parsed = /** @type {MergedSafeParseReturn} */ (
65 | isServer
66 | ? merged.safeParse(processEnv) // on server we can validate all env vars
67 | : client.safeParse(processEnv) // on client we can only validate the ones that are exposed
68 | );
69 |
70 | if (parsed.success === false) {
71 | console.error(
72 | "❌ Invalid environment variables:",
73 | parsed.error.flatten().fieldErrors,
74 | );
75 | throw new Error("Invalid environment variables");
76 | }
77 |
78 | env = new Proxy(parsed.data, {
79 | get(target, prop) {
80 | if (typeof prop !== "string") return undefined;
81 | // Throw a descriptive error if a server-side env var is accessed on the client
82 | // Otherwise it would just be returning `undefined` and be annoying to debug
83 | if (!isServer && !prop.startsWith("NEXT_PUBLIC_"))
84 | throw new Error(
85 | process.env.NODE_ENV === "production"
86 | ? "❌ Attempted to access a server-side environment variable on the client"
87 | : `❌ Attempted to access server-side environment variable '${prop}' on the client`,
88 | );
89 | return target[/** @type {keyof typeof target} */ (prop)];
90 | },
91 | });
92 | }
93 |
94 | export { env };
95 |
--------------------------------------------------------------------------------
/src/ui/select.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as React from "react";
4 | import * as SelectPrimitive from "@radix-ui/react-select";
5 | import { Check, ChevronDown } from "lucide-react";
6 |
7 | import { cn } from "~/utils/cn";
8 |
9 | const Select = SelectPrimitive.Root;
10 |
11 | const SelectGroup = SelectPrimitive.Group;
12 |
13 | const SelectValue = SelectPrimitive.Value;
14 |
15 | const SelectTrigger = React.forwardRef<
16 | React.ElementRef,
17 | React.ComponentPropsWithoutRef
18 | >(({ className, children, ...props }, ref) => (
19 |
27 | {children}
28 |
29 |
30 | ));
31 | SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
32 |
33 | const SelectContent = React.forwardRef<
34 | React.ElementRef,
35 | React.ComponentPropsWithoutRef
36 | >(({ className, children, ...props }, ref) => (
37 |
38 |
46 |
47 | {children}
48 |
49 |
50 |
51 | ));
52 | SelectContent.displayName = SelectPrimitive.Content.displayName;
53 |
54 | const SelectLabel = React.forwardRef<
55 | React.ElementRef,
56 | React.ComponentPropsWithoutRef
57 | >(({ className, ...props }, ref) => (
58 |
66 | ));
67 | SelectLabel.displayName = SelectPrimitive.Label.displayName;
68 |
69 | const SelectItem = React.forwardRef<
70 | React.ElementRef,
71 | React.ComponentPropsWithoutRef
72 | >(({ className, children, ...props }, ref) => (
73 |
81 |
82 |
83 |
84 |
85 |
86 |
87 | {children}
88 |
89 | ));
90 | SelectItem.displayName = SelectPrimitive.Item.displayName;
91 |
92 | const SelectSeparator = React.forwardRef<
93 | React.ElementRef,
94 | React.ComponentPropsWithoutRef
95 | >(({ className, ...props }, ref) => (
96 |
101 | ));
102 | SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
103 |
104 | export {
105 | Select,
106 | SelectGroup,
107 | SelectValue,
108 | SelectTrigger,
109 | SelectContent,
110 | SelectLabel,
111 | SelectItem,
112 | SelectSeparator,
113 | };
114 |
--------------------------------------------------------------------------------
/src/server/api/trpc.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * YOU PROBABLY DON'T NEED TO EDIT THIS FILE, UNLESS:
3 | * 1. You want to modify request context (see Part 1).
4 | * 2. You want to create a new middleware or type of procedure (see Part 3).
5 | *
6 | * tl;dr - This is where all the tRPC server stuff is created and plugged in.
7 | * The pieces you will need to use are documented accordingly near the end.
8 | */
9 |
10 | /**
11 | * 1. CONTEXT
12 | *
13 | * This section defines the "contexts" that are available in the backend API.
14 | *
15 | * These allow you to access things when processing a request, like the
16 | * database, the session, etc.
17 | */
18 |
19 | /**
20 | * 2. INITIALIZATION
21 | *
22 | * This is where the tRPC API is initialized, connecting the context and
23 | * transformer.
24 | */
25 | import { TRPCError, initTRPC } from "@trpc/server";
26 | import { type CreateNextContextOptions } from "@trpc/server/adapters/next";
27 | import { type Session } from "next-auth";
28 | import superjson from "superjson";
29 | import { ZodError } from "zod";
30 |
31 | import { getServerAuthSession } from "~/server/auth";
32 | import { prisma } from "~/server/db";
33 |
34 | type CreateContextOptions = {
35 | session: Session | null;
36 | };
37 |
38 | /**
39 | * This helper generates the "internals" for a tRPC context. If you need to use
40 | * it, you can export it from here.
41 | *
42 | * Examples of things you may need it for:
43 | * - testing, so we don't have to mock Next.js' req/res
44 | * - tRPC's `createSSGHelpers`, where we don't have req/res
45 | *
46 | * @see https://create.t3.gg/en/usage/trpc#-servertrpccontextts
47 | */
48 | export const createInnerTRPCContext = (opts: CreateContextOptions) => {
49 | return {
50 | session: opts.session,
51 | prisma,
52 | };
53 | };
54 |
55 | /**
56 | * This is the actual context you will use in your router. It will be used to
57 | * process every request that goes through your tRPC endpoint.
58 | *
59 | * @see https://trpc.io/docs/context
60 | */
61 | export const createTRPCContext = async (opts: CreateNextContextOptions) => {
62 | const { req, res } = opts;
63 |
64 | // Get the session from the server using the getServerSession wrapper function
65 | const session = await getServerAuthSession({ req, res });
66 |
67 | return createInnerTRPCContext({
68 | session,
69 | });
70 | };
71 |
72 | const t = initTRPC.context().create({
73 | transformer: superjson,
74 | errorFormatter({ shape, error }) {
75 | return {
76 | ...shape,
77 | data: {
78 | ...shape.data,
79 | zodError:
80 | error.cause instanceof ZodError ? error.cause.flatten() : null,
81 | },
82 | };
83 | },
84 | });
85 |
86 | /**
87 | * 3. ROUTER & PROCEDURE (THE IMPORTANT BIT)
88 | *
89 | * These are the pieces you use to build your tRPC API. You should import these
90 | * a lot in the "/src/server/api/routers" directory.
91 | */
92 |
93 | /**
94 | * This is how you create new routers and sub-routers in your tRPC API.
95 | *
96 | * @see https://trpc.io/docs/router
97 | */
98 | export const createTRPCRouter = t.router;
99 |
100 | /**
101 | * Public (unauthenticated) procedure
102 | *
103 | * This is the base piece you use to build new queries and mutations on your
104 | * tRPC API. It does not guarantee that a user querying is authorized, but you
105 | * can still access user session data if they are logged in.
106 | */
107 | export const publicProcedure = t.procedure;
108 |
109 | /**
110 | * Reusable middleware that enforces users are logged in before running the
111 | * procedure.
112 | */
113 | const enforceUserIsAuthed = t.middleware(({ ctx, next }) => {
114 | if (!ctx.session || !ctx.session.user) {
115 | throw new TRPCError({ code: "UNAUTHORIZED" });
116 | }
117 | return next({
118 | ctx: {
119 | // infers the `session` as non-nullable
120 | session: { ...ctx.session, user: ctx.session.user },
121 | },
122 | });
123 | });
124 |
125 | /**
126 | * Protected (authenticated) procedure
127 | *
128 | * If you want a query or mutation to ONLY be accessible to logged in users, use
129 | * this. It verifies the session is valid and guarantees `ctx.session.user` is
130 | * not null.
131 | *
132 | * @see https://trpc.io/docs/procedures
133 | */
134 | export const protectedProcedure = t.procedure.use(enforceUserIsAuthed);
135 |
--------------------------------------------------------------------------------
/src/ui/dialog.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as React from "react";
4 | import * as DialogPrimitive from "@radix-ui/react-dialog";
5 | import { X } from "lucide-react";
6 |
7 | import { cn } from "~/utils/cn";
8 |
9 | const Dialog = DialogPrimitive.Root;
10 |
11 | const DialogTrigger = DialogPrimitive.Trigger;
12 | const DialogClose = DialogPrimitive.Close;
13 |
14 | const DialogPortal = ({
15 | className,
16 | children,
17 | ...props
18 | }: DialogPrimitive.DialogPortalProps) => (
19 |
20 |
21 | {children}
22 |
23 |
24 | );
25 | DialogPortal.displayName = DialogPrimitive.Portal.displayName;
26 |
27 | const DialogOverlay = React.forwardRef<
28 | React.ElementRef,
29 | React.ComponentPropsWithoutRef
30 | >(({ className, ...props }, ref) => (
31 |
39 | ));
40 | DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
41 |
42 | const DialogContent = React.forwardRef<
43 | React.ElementRef,
44 | React.ComponentPropsWithoutRef
45 | >(({ className, children, ...props }, ref) => (
46 |
47 |
48 |
57 | {children}
58 |
59 |
60 | Close
61 |
62 |
63 |
64 | ));
65 | DialogContent.displayName = DialogPrimitive.Content.displayName;
66 |
67 | const DialogHeader = ({
68 | className,
69 | ...props
70 | }: React.HTMLAttributes) => (
71 |
78 | );
79 | DialogHeader.displayName = "DialogHeader";
80 |
81 | const DialogFooter = ({
82 | className,
83 | ...props
84 | }: React.HTMLAttributes) => (
85 |
92 | );
93 | DialogFooter.displayName = "DialogFooter";
94 |
95 | const DialogTitle = React.forwardRef<
96 | React.ElementRef,
97 | React.ComponentPropsWithoutRef
98 | >(({ className, ...props }, ref) => (
99 |
108 | ));
109 | DialogTitle.displayName = DialogPrimitive.Title.displayName;
110 |
111 | const DialogDescription = React.forwardRef<
112 | React.ElementRef,
113 | React.ComponentPropsWithoutRef
114 | >(({ className, ...props }, ref) => (
115 |
120 | ));
121 | DialogDescription.displayName = DialogPrimitive.Description.displayName;
122 |
123 | export {
124 | Dialog,
125 | DialogTrigger,
126 | DialogClose,
127 | DialogContent,
128 | DialogHeader,
129 | DialogFooter,
130 | DialogTitle,
131 | DialogDescription,
132 | };
133 |
--------------------------------------------------------------------------------
/src/pages/posts.tsx:
--------------------------------------------------------------------------------
1 | import { PostCategory } from "@prisma/client";
2 | import { useSession } from "next-auth/react";
3 | import { Controller } from "react-hook-form";
4 | import { z } from "zod";
5 |
6 | import { api, type RouterOutputs } from "~/utils/api";
7 | import { useZodForm } from "~/utils/zod-form";
8 | import { Avatar, AvatarFallback, AvatarImage } from "~/ui/avatar";
9 | import { Button } from "~/ui/button";
10 | import {
11 | Dialog,
12 | DialogClose,
13 | DialogContent,
14 | DialogDescription,
15 | DialogFooter,
16 | DialogHeader,
17 | DialogTitle,
18 | DialogTrigger,
19 | } from "~/ui/dialog";
20 | import { Input } from "~/ui/input";
21 | import { Label } from "~/ui/label";
22 | import {
23 | Select,
24 | SelectContent,
25 | SelectItem,
26 | SelectTrigger,
27 | SelectValue,
28 | } from "~/ui/select";
29 | import { Textarea } from "~/ui/text-area";
30 |
31 | // This schema is reused on the backend
32 | export const postCreateSchema = z.object({
33 | title: z.string().min(3).max(20),
34 | body: z.string().min(20),
35 | category: z.nativeEnum(PostCategory),
36 | });
37 |
38 | function CreatePostForm() {
39 | const { data: session } = useSession();
40 |
41 | const methods = useZodForm({
42 | schema: postCreateSchema,
43 | });
44 |
45 | const utils = api.useContext();
46 | const createPost = api.post.create.useMutation({
47 | onSettled: async () => {
48 | await utils.post.invalidate();
49 | methods.reset();
50 | },
51 | });
52 |
53 | const onSubmit = methods.handleSubmit(
54 | (data) => {
55 | createPost.mutate(data);
56 | },
57 | (e) => {
58 | console.log("Whoops... something went wrong!");
59 | console.error(e);
60 | },
61 | );
62 |
63 | return (
64 |
123 | );
124 | }
125 |
126 | function PostCard(props: { post: RouterOutputs["post"]["getAll"][number] }) {
127 | const { data: session } = useSession();
128 | const { post } = props;
129 | const utils = api.useContext();
130 | const deletePost = api.post.delete.useMutation({
131 | onSettled: async () => {
132 | await utils.post.invalidate();
133 | },
134 | });
135 | return (
136 |
137 |
138 |
139 | {post.author.name.substring(0, 2)}
140 |
141 |
142 |
{post.title}
143 |
{post.body}
144 |
145 |
177 |
178 | );
179 | }
180 |
181 | export default function PostPage() {
182 | const { data: posts } = api.post.getAll.useQuery();
183 |
184 | return (
185 |
186 |
187 |
188 | {posts?.map((post) => (
189 |
190 | ))}
191 |
192 |
193 | );
194 | }
195 |
--------------------------------------------------------------------------------