diff --git a/.env.example b/.env.example new file mode 100644 index 00000000..56d4230e --- /dev/null +++ b/.env.example @@ -0,0 +1,26 @@ +# Since the ".env" file is gitignored, you can use the ".env.example" file to +# build a new ".env" file when you clone the repo. Keep this file up-to-date +# when you add new variables to `.env`. + +# This file will be committed to version control, so make sure not to have any +# secrets in it. If you are cloning this repo, create a copy of this file named +# ".env" and populate it with your secrets. + +# When adding additional environment variables, the schema in "/src/env.mjs" +# should be updated accordingly. + +# Drizzle +# Get the Database URL from the "prisma" dropdown selector in PlanetScale. +# Change the query params at the end of the URL to "?ssl={"rejectUnauthorized":true}" +DATABASE_URL='mysql://YOUR_MYSQL_URL_HERE?ssl={"rejectUnauthorized":true}' + +# Next Auth +# You can generate a new secret on the command line with: +# openssl rand -base64 32 +# https://next-auth.js.org/configuration/options#secret +# NEXTAUTH_SECRET="" +NEXTAUTH_URL="http://localhost:3000" + +# Next Auth Discord Provider +DISCORD_CLIENT_ID="" +DISCORD_CLIENT_SECRET="" diff --git a/.eslintrc.cjs b/.eslintrc.cjs new file mode 100644 index 00000000..79cb5118 --- /dev/null +++ b/.eslintrc.cjs @@ -0,0 +1,36 @@ +/** @type {import("eslint").Linter.Config} */ +const config = { + parser: "@typescript-eslint/parser", + parserOptions: { + project: true, + }, + plugins: ["@typescript-eslint"], + extends: [ + "next/core-web-vitals", + "plugin:@typescript-eslint/recommended-type-checked", + "plugin:@typescript-eslint/stylistic-type-checked", + ], + rules: { + // These opinionated rules are enabled in stylistic-type-checked above. + // Feel free to reconfigure them to your own preference. + "@typescript-eslint/array-type": "off", + "@typescript-eslint/consistent-type-definitions": "off", + + "@typescript-eslint/consistent-type-imports": [ + "warn", + { + prefer: "type-imports", + fixStyle: "inline-type-imports", + }, + ], + "@typescript-eslint/no-unused-vars": ["warn", { argsIgnorePattern: "^_" }], + "@typescript-eslint/no-misused-promises": [ + 2, + { + checksVoidReturn: { attributes: false }, + }, + ], + }, +}; + +module.exports = config; diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..2971a0bd --- /dev/null +++ b/.gitignore @@ -0,0 +1,42 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# database +/prisma/db.sqlite +/prisma/db.sqlite-journal + +# next.js +/.next/ +/out/ +next-env.d.ts + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# local env files +# 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 +.env +.env*.local + +# vercel +.vercel + +# typescript +*.tsbuildinfo diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..22073e71 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "spellright.addToSystemDictionary": true +} \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 00000000..956f2c96 --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# Kan + +The open source Trello alternative. diff --git a/bun.lockb b/bun.lockb new file mode 100755 index 00000000..2e89ab83 Binary files /dev/null and b/bun.lockb differ diff --git a/drizzle.config.ts b/drizzle.config.ts new file mode 100644 index 00000000..0f325756 --- /dev/null +++ b/drizzle.config.ts @@ -0,0 +1,12 @@ +import { type Config } from "drizzle-kit"; + +import { env } from "~/env.mjs"; + +export default { + schema: "./src/server/db/schema.ts", + driver: "mysql2", + dbCredentials: { + connectionString: env.DATABASE_URL, + }, + tablesFilter: ["kan_*"], +} satisfies Config; diff --git a/next.config.mjs b/next.config.mjs new file mode 100644 index 00000000..0914d313 --- /dev/null +++ b/next.config.mjs @@ -0,0 +1,10 @@ +/** + * Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially useful + * for Docker builds. + */ +await import("./src/env.mjs"); + +/** @type {import("next").NextConfig} */ +const config = {}; + +export default config; diff --git a/package.json b/package.json new file mode 100644 index 00000000..4f412b0a --- /dev/null +++ b/package.json @@ -0,0 +1,52 @@ +{ + "name": "kan", + "version": "0.1.0", + "private": true, + "scripts": { + "build": "next build", + "db:push": "dotenv drizzle-kit push:mysql", + "db:studio": "dotenv drizzle-kit studio", + "dev": "next dev", + "lint": "next lint", + "start": "next start" + }, + "dependencies": { + "@auth/drizzle-adapter": "^0.3.2", + "@planetscale/database": "^1.11.0", + "@t3-oss/env-nextjs": "^0.7.0", + "@tanstack/react-query": "^4.32.6", + "@trpc/client": "^10.37.1", + "@trpc/next": "^10.37.1", + "@trpc/react-query": "^10.37.1", + "@trpc/server": "^10.37.1", + "drizzle-orm": "^0.28.5", + "next": "^13.5.4", + "next-auth": "^4.23.0", + "react": "18.2.0", + "react-dom": "18.2.0", + "superjson": "^1.13.1", + "zod": "^3.22.4" + }, + "devDependencies": { + "@types/eslint": "^8.44.2", + "@types/node": "^18.16.0", + "@types/react": "^18.2.20", + "@types/react-dom": "^18.2.7", + "@typescript-eslint/eslint-plugin": "^6.3.0", + "@typescript-eslint/parser": "^6.3.0", + "autoprefixer": "^10.4.14", + "dotenv-cli": "^7.3.0", + "drizzle-kit": "^0.19.13", + "eslint": "^8.47.0", + "eslint-config-next": "^13.5.4", + "mysql2": "^3.6.1", + "postcss": "^8.4.27", + "prettier": "^3.0.0", + "prettier-plugin-tailwindcss": "^0.5.1", + "tailwindcss": "^3.3.3", + "typescript": "^5.1.6" + }, + "ct3aMetadata": { + "initVersion": "7.22.0" + } +} diff --git a/postcss.config.cjs b/postcss.config.cjs new file mode 100644 index 00000000..e305dd92 --- /dev/null +++ b/postcss.config.cjs @@ -0,0 +1,8 @@ +const config = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; + +module.exports = config; diff --git a/prettier.config.mjs b/prettier.config.mjs new file mode 100644 index 00000000..2d2fa4c9 --- /dev/null +++ b/prettier.config.mjs @@ -0,0 +1,6 @@ +/** @type {import('prettier').Config & import('prettier-plugin-tailwindcss').options} */ +const config = { + plugins: ["prettier-plugin-tailwindcss"], +}; + +export default config; diff --git a/public/favicon.ico b/public/favicon.ico new file mode 100644 index 00000000..60c702aa Binary files /dev/null and b/public/favicon.ico differ diff --git a/src/app/api/auth/[...nextauth]/route.ts b/src/app/api/auth/[...nextauth]/route.ts new file mode 100644 index 00000000..74f55343 --- /dev/null +++ b/src/app/api/auth/[...nextauth]/route.ts @@ -0,0 +1,11 @@ +import NextAuth from "next-auth"; + +import { authOptions } from "~/server/auth"; + +// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment +const handler = NextAuth(authOptions); +export { handler as GET, handler as POST }; + +export const runtime = "edge"; +export const preferredRegion = 'lhr1'; +export const dynamic = 'force-dynamic' diff --git a/src/app/api/trpc/[trpc]/route.ts b/src/app/api/trpc/[trpc]/route.ts new file mode 100644 index 00000000..9e63b2d6 --- /dev/null +++ b/src/app/api/trpc/[trpc]/route.ts @@ -0,0 +1,28 @@ +import { fetchRequestHandler } from "@trpc/server/adapters/fetch"; +import { type NextRequest } from "next/server"; + +import { env } from "~/env.mjs"; +import { appRouter } from "~/server/api/root"; +import { createTRPCContext } from "~/server/api/trpc"; + +const handler = (req: NextRequest) => + fetchRequestHandler({ + endpoint: "/api/trpc", + req, + router: appRouter, + createContext: () => createTRPCContext({ req }), + onError: + env.NODE_ENV === "development" + ? ({ path, error }) => { + console.error( + `❌ tRPC failed on ${path ?? ""}: ${error.message}` + ); + } + : undefined, + }); + +export { handler as GET, handler as POST }; + +export const runtime = "edge"; +export const preferredRegion = 'lhr1'; +export const dynamic = 'force-dynamic' diff --git a/src/app/layout.tsx b/src/app/layout.tsx new file mode 100644 index 00000000..6081a715 --- /dev/null +++ b/src/app/layout.tsx @@ -0,0 +1,31 @@ +import "~/styles/globals.css"; + +import { Plus_Jakarta_Sans } from "next/font/google"; +import { headers } from "next/headers"; + +import { TRPCReactProvider } from "~/trpc/react"; + +const jakarta = Plus_Jakarta_Sans({ + subsets: ["latin"], + variable: "--font-sans", +}); + +export const metadata = { + title: "Kan", + description: "The open source Trello alternative", + icons: [{ rel: "icon", url: "/favicon.ico" }], +}; + +export default function RootLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + + + {children} + + + ); +} diff --git a/src/app/page.tsx b/src/app/page.tsx new file mode 100644 index 00000000..2eae4325 --- /dev/null +++ b/src/app/page.tsx @@ -0,0 +1,7 @@ +export default function Home() { + return ( +
+

Kan

+
+ ); +} diff --git a/src/env.mjs b/src/env.mjs new file mode 100644 index 00000000..4b627b73 --- /dev/null +++ b/src/env.mjs @@ -0,0 +1,62 @@ +import { createEnv } from "@t3-oss/env-nextjs"; +import { z } from "zod"; + +export const env = createEnv({ + /** + * Specify your server-side environment variables schema here. This way you can ensure the app + * isn't built with invalid env vars. + */ + server: { + DATABASE_URL: z + .string() + .url() + .refine( + (str) => !str.includes("YOUR_MYSQL_URL_HERE"), + "You forgot to change the default URL", + ), + NODE_ENV: z + .enum(["development", "test", "production"]) + .default("development"), + NEXTAUTH_SECRET: + process.env.NODE_ENV === "production" + ? z.string() + : z.string().optional(), + NEXTAUTH_URL: z.preprocess( + // This makes Vercel deployments not fail if you don't set NEXTAUTH_URL + // Since NextAuth.js automatically uses the VERCEL_URL if present. + (str) => process.env.VERCEL_URL ?? str, + // VERCEL_URL doesn't include `https` so it cant be validated as a URL + process.env.VERCEL ? z.string() : z.string().url(), + ), + }, + + /** + * Specify your client-side environment variables schema here. This way you can ensure the app + * isn't built with invalid env vars. To expose them to the client, prefix them with + * `NEXT_PUBLIC_`. + */ + client: { + // NEXT_PUBLIC_CLIENTVAR: z.string(), + }, + + /** + * You can't destruct `process.env` as a regular object in the Next.js edge runtimes (e.g. + * middlewares) or client-side so we need to destruct manually. + */ + runtimeEnv: { + DATABASE_URL: process.env.DATABASE_URL, + NODE_ENV: process.env.NODE_ENV, + NEXTAUTH_SECRET: process.env.NEXTAUTH_SECRET, + NEXTAUTH_URL: process.env.NEXTAUTH_URL, + }, + /** + * Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially + * useful for Docker builds. + */ + skipValidation: !!process.env.SKIP_ENV_VALIDATION, + /** + * Makes it so that empty strings are treated as undefined. + * `SOME_VAR: z.string()` and `SOME_VAR=''` will throw an error. + */ + emptyStringAsUndefined: true, +}); diff --git a/src/server/api/root.ts b/src/server/api/root.ts new file mode 100644 index 00000000..976ea23f --- /dev/null +++ b/src/server/api/root.ts @@ -0,0 +1,14 @@ +import { boardRouter } from "~/server/api/routers/board"; +import { createTRPCRouter } from "~/server/api/trpc"; + +/** + * This is the primary router for your server. + * + * All routers added in /api/routers should be manually added here. + */ +export const appRouter = createTRPCRouter({ + board: boardRouter, +}); + +// export type definition of API +export type AppRouter = typeof appRouter; diff --git a/src/server/api/routers/board.ts b/src/server/api/routers/board.ts new file mode 100644 index 00000000..f725fe41 --- /dev/null +++ b/src/server/api/routers/board.ts @@ -0,0 +1,14 @@ +import { + createTRPCRouter, + publicProcedure, +} from "~/server/api/trpc"; + +export const boardRouter = createTRPCRouter({ + all: publicProcedure.query(({ ctx }) => { + const userId = ctx.session?.user.id; + + if (!userId) return; + + return ctx.db.query.boards.findMany(); + }) +}); diff --git a/src/server/api/trpc.ts b/src/server/api/trpc.ts new file mode 100644 index 00000000..37cc16a5 --- /dev/null +++ b/src/server/api/trpc.ts @@ -0,0 +1,130 @@ +/** + * YOU PROBABLY DON'T NEED TO EDIT THIS FILE, UNLESS: + * 1. You want to modify request context (see Part 1). + * 2. You want to create a new middleware or type of procedure (see Part 3). + * + * TL;DR - This is where all the tRPC server stuff is created and plugged in. The pieces you will + * need to use are documented accordingly near the end. + */ + +import { initTRPC, TRPCError } from "@trpc/server"; +import { type NextRequest } from "next/server"; +import superjson from "superjson"; +import { ZodError } from "zod"; + +import { getServerAuthSession } from "~/server/auth"; +import { db } from "~/server/db"; + +/** + * 1. CONTEXT + * + * This section defines the "contexts" that are available in the backend API. + * + * These allow you to access things when processing a request, like the database, the session, etc. + */ + +interface CreateContextOptions { + headers: Headers; +} + +/** + * This helper generates the "internals" for a tRPC context. If you need to use it, you can export + * it from here. + * + * Examples of things you may need it for: + * - testing, so we don't have to mock Next.js' req/res + * - tRPC's `createSSGHelpers`, where we don't have req/res + * + * @see https://create.t3.gg/en/usage/trpc#-serverapitrpcts + */ +export const createInnerTRPCContext = async (opts: CreateContextOptions) => { + const session = await getServerAuthSession(); + + return { + session, + headers: opts.headers, + db, + }; +}; + +/** + * This is the actual context you will use in your router. It will be used to process every request + * that goes through your tRPC endpoint. + * + * @see https://trpc.io/docs/context + */ +export const createTRPCContext = async (opts: { req: NextRequest }) => { + // Fetch stuff that depends on the request + + return await createInnerTRPCContext({ + headers: opts.req.headers, + }); +}; + +/** + * 2. INITIALIZATION + * + * This is where the tRPC API is initialized, connecting the context and transformer. We also parse + * ZodErrors so that you get typesafety on the frontend if your procedure fails due to validation + * errors on the backend. + */ + +const t = initTRPC.context().create({ + transformer: superjson, + errorFormatter({ shape, error }) { + return { + ...shape, + data: { + ...shape.data, + zodError: + error.cause instanceof ZodError ? error.cause.flatten() : null, + }, + }; + }, +}); + +/** + * 3. ROUTER & PROCEDURE (THE IMPORTANT BIT) + * + * These are the pieces you use to build your tRPC API. You should import these a lot in the + * "/src/server/api/routers" directory. + */ + +/** + * This is how you create new routers and sub-routers in your tRPC API. + * + * @see https://trpc.io/docs/router + */ +export const createTRPCRouter = t.router; + +/** + * Public (unauthenticated) procedure + * + * This is the base piece you use to build new queries and mutations on your tRPC API. It does not + * guarantee that a user querying is authorized, but you can still access user session data if they + * are logged in. + */ +export const publicProcedure = t.procedure; + +/** Reusable middleware that enforces users are logged in before running the procedure. */ +const enforceUserIsAuthed = t.middleware(({ ctx, next }) => { + if (!ctx.session || !ctx.session.user) { + throw new TRPCError({ code: "UNAUTHORIZED" }); + } + return next({ + ctx: { + // infers the `session` as non-nullable + session: { ...ctx.session, user: ctx.session.user }, + }, + }); +}); + +/** + * Protected (authenticated) procedure + * + * If you want a query or mutation to ONLY be accessible to logged in users, use this. It verifies + * the session is valid and guarantees `ctx.session.user` is not null. + * + * @see https://trpc.io/docs/procedures + */ +export const protectedProcedure = t.procedure.use(enforceUserIsAuthed); diff --git a/src/server/auth.ts b/src/server/auth.ts new file mode 100644 index 00000000..98d7ba72 --- /dev/null +++ b/src/server/auth.ts @@ -0,0 +1,68 @@ +import { DrizzleAdapter } from "@auth/drizzle-adapter"; +import { + getServerSession, + type DefaultSession, + type NextAuthOptions, +} from "next-auth"; +import DiscordProvider from "next-auth/providers/discord"; + +import { env } from "~/env.mjs"; +import { db } from "~/server/db"; +import { mysqlTable } from "~/server/db/schema"; + +/** + * Module augmentation for `next-auth` types. Allows us to add custom properties to the `session` + * object and keep type safety. + * + * @see https://next-auth.js.org/getting-started/typescript#module-augmentation + */ +declare module "next-auth" { + interface Session extends DefaultSession { + user: { + id: string; + // ...other properties + // role: UserRole; + } & DefaultSession["user"]; + } + + // interface User { + // // ...other properties + // // role: UserRole; + // } +} + +/** + * Options for NextAuth.js used to configure adapters, providers, callbacks, etc. + * + * @see https://next-auth.js.org/configuration/options + */ +export const authOptions: NextAuthOptions = { + callbacks: { + session: ({ session, user }) => ({ + ...session, + user: { + ...session.user, + id: user.id, + }, + }), + }, + adapter: DrizzleAdapter(db, mysqlTable), + providers: [ + /** + * ...add more providers here. + * + * Most other providers require a bit more work than the Discord provider. For example, the + * GitHub provider requires you to add the `refresh_token_expires_in` field to the Account + * model. Refer to the NextAuth.js docs for the provider you want to use. Example: + * + * @see https://next-auth.js.org/providers/github + */ + ], +}; + +/** + * Wrapper for `getServerSession` so that you don't need to import the `authOptions` in every file. + * + * @see https://next-auth.js.org/configuration/nextjs + */ +export const getServerAuthSession = () => getServerSession(authOptions); diff --git a/src/server/db/index.ts b/src/server/db/index.ts new file mode 100644 index 00000000..2a0c8e8f --- /dev/null +++ b/src/server/db/index.ts @@ -0,0 +1,12 @@ +import { Client } from "@planetscale/database"; +import { drizzle } from "drizzle-orm/planetscale-serverless"; + +import { env } from "~/env.mjs"; +import * as schema from "./schema"; + +export const db = drizzle( + new Client({ + url: env.DATABASE_URL, + }).connection(), + { schema } +); diff --git a/src/server/db/schema.ts b/src/server/db/schema.ts new file mode 100644 index 00000000..d2b50d31 --- /dev/null +++ b/src/server/db/schema.ts @@ -0,0 +1,105 @@ +import { relations, sql } from "drizzle-orm"; +import { + bigint, + index, + int, + mysqlTableCreator, + primaryKey, + text, + timestamp, + varchar, +} from "drizzle-orm/mysql-core"; +import { type AdapterAccount } from "next-auth/adapters"; + +/** + * This is an example of how to use the multi-project schema feature of Drizzle ORM. Use the same + * database instance for multiple projects. + * + * @see https://orm.drizzle.team/docs/goodies#multi-project-schema + */ +export const mysqlTable = mysqlTableCreator((name) => `kan_${name}`); + +export const boards = mysqlTable( + "board", + { + id: bigint("id", { mode: "number" }).primaryKey().autoincrement(), + name: varchar("name", { length: 255 }), + createdBy: varchar("createdBy", { length: 255 }).notNull(), + createdAt: timestamp("created_at") + .default(sql`CURRENT_TIMESTAMP`) + .notNull(), + updatedAt: timestamp("updatedAt").onUpdateNow(), + }, +); + +export const users = mysqlTable("user", { + id: varchar("id", { length: 255 }).notNull().primaryKey(), + name: varchar("name", { length: 255 }), + email: varchar("email", { length: 255 }).notNull(), + emailVerified: timestamp("emailVerified", { + mode: "date", + fsp: 3, + }).default(sql`CURRENT_TIMESTAMP(3)`), + image: varchar("image", { length: 255 }), +}); + +export const usersRelations = relations(users, ({ many }) => ({ + accounts: many(accounts), +})); + +export const accounts = mysqlTable( + "account", + { + userId: varchar("userId", { length: 255 }).notNull(), + type: varchar("type", { length: 255 }) + .$type() + .notNull(), + provider: varchar("provider", { length: 255 }).notNull(), + providerAccountId: varchar("providerAccountId", { length: 255 }).notNull(), + refresh_token: text("refresh_token"), + access_token: text("access_token"), + expires_at: int("expires_at"), + token_type: varchar("token_type", { length: 255 }), + scope: varchar("scope", { length: 255 }), + id_token: text("id_token"), + session_state: varchar("session_state", { length: 255 }), + }, + (account) => ({ + compoundKey: primaryKey(account.provider, account.providerAccountId), + userIdIdx: index("userId_idx").on(account.userId), + }) +); + +export const accountsRelations = relations(accounts, ({ one }) => ({ + user: one(users, { fields: [accounts.userId], references: [users.id] }), +})); + +export const sessions = mysqlTable( + "session", + { + sessionToken: varchar("sessionToken", { length: 255 }) + .notNull() + .primaryKey(), + userId: varchar("userId", { length: 255 }).notNull(), + expires: timestamp("expires", { mode: "date" }).notNull(), + }, + (session) => ({ + userIdIdx: index("userId_idx").on(session.userId), + }) +); + +export const sessionsRelations = relations(sessions, ({ one }) => ({ + user: one(users, { fields: [sessions.userId], references: [users.id] }), +})); + +export const verificationTokens = mysqlTable( + "verificationToken", + { + identifier: varchar("identifier", { length: 255 }).notNull(), + token: varchar("token", { length: 255 }).notNull(), + expires: timestamp("expires", { mode: "date" }).notNull(), + }, + (vt) => ({ + compoundKey: primaryKey(vt.identifier, vt.token), + }) +); diff --git a/src/styles/globals.css b/src/styles/globals.css new file mode 100644 index 00000000..b5c61c95 --- /dev/null +++ b/src/styles/globals.css @@ -0,0 +1,3 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; diff --git a/src/trpc/react.tsx b/src/trpc/react.tsx new file mode 100644 index 00000000..6429613d --- /dev/null +++ b/src/trpc/react.tsx @@ -0,0 +1,47 @@ +"use client"; + +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { loggerLink, unstable_httpBatchStreamLink } from "@trpc/client"; +import { createTRPCReact } from "@trpc/react-query"; +import { useState } from "react"; + +import { type AppRouter } from "~/server/api/root"; +import { getUrl, transformer } from "./shared"; + +export const api = createTRPCReact(); + +export function TRPCReactProvider(props: { + children: React.ReactNode; + headers: Headers; +}) { + const [queryClient] = useState(() => new QueryClient()); + + const [trpcClient] = useState(() => + api.createClient({ + transformer, + links: [ + loggerLink({ + enabled: (op) => + process.env.NODE_ENV === "development" || + (op.direction === "down" && op.result instanceof Error), + }), + unstable_httpBatchStreamLink({ + url: getUrl(), + headers() { + const heads = new Map(props.headers); + heads.set("x-trpc-source", "react"); + return Object.fromEntries(heads); + }, + }), + ], + }) + ); + + return ( + + + {props.children} + + + ); +} diff --git a/src/trpc/server.ts b/src/trpc/server.ts new file mode 100644 index 00000000..6984f458 --- /dev/null +++ b/src/trpc/server.ts @@ -0,0 +1,28 @@ +import { + createTRPCProxyClient, + loggerLink, + unstable_httpBatchStreamLink, +} from "@trpc/client"; +import { headers } from "next/headers"; + +import { type AppRouter } from "~/server/api/root"; +import { getUrl, transformer } from "./shared"; + +export const api = createTRPCProxyClient({ + transformer, + links: [ + loggerLink({ + enabled: (op) => + process.env.NODE_ENV === "development" || + (op.direction === "down" && op.result instanceof Error), + }), + unstable_httpBatchStreamLink({ + url: getUrl(), + headers() { + const heads = new Map(headers()); + heads.set("x-trpc-source", "rsc"); + return Object.fromEntries(heads); + }, + }), + ], +}); diff --git a/src/trpc/shared.ts b/src/trpc/shared.ts new file mode 100644 index 00000000..46005045 --- /dev/null +++ b/src/trpc/shared.ts @@ -0,0 +1,30 @@ +import { type inferRouterInputs, type inferRouterOutputs } from "@trpc/server"; +import superjson from "superjson"; + +import { type AppRouter } from "~/server/api/root"; + +export const transformer = superjson; + +function getBaseUrl() { + if (typeof window !== "undefined") return ""; + if (process.env.VERCEL_URL) return `https://${process.env.VERCEL_URL}`; + return `http://localhost:${process.env.PORT ?? 3000}`; +} + +export function getUrl() { + return getBaseUrl() + "/api/trpc"; +} + +/** + * Inference helper for inputs. + * + * @example type HelloInput = RouterInputs['example']['hello'] + */ +export type RouterInputs = inferRouterInputs; + +/** + * Inference helper for outputs. + * + * @example type HelloOutput = RouterOutputs['example']['hello'] + */ +export type RouterOutputs = inferRouterOutputs; diff --git a/tailwind.config.ts b/tailwind.config.ts new file mode 100644 index 00000000..f06488f9 --- /dev/null +++ b/tailwind.config.ts @@ -0,0 +1,14 @@ +import { type Config } from "tailwindcss"; +import { fontFamily } from "tailwindcss/defaultTheme"; + +export default { + content: ["./src/**/*.tsx"], + theme: { + extend: { + fontFamily: { + sans: ["var(--font-sans)", ...fontFamily.sans], + }, + }, + }, + plugins: [], +} satisfies Config; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 00000000..1dfa3a89 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,35 @@ +{ + "compilerOptions": { + "target": "es2017", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "checkJs": true, + "skipLibCheck": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "node", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "noUncheckedIndexedAccess": true, + "baseUrl": ".", + "paths": { + "~/*": ["./src/*"] + }, + "plugins": [{ "name": "next" }] + }, + "include": [ + ".eslintrc.cjs", + "next-env.d.ts", + "**/*.ts", + "**/*.tsx", + "**/*.cjs", + "**/*.mjs", + ".next/types/**/*.ts" + ], + "exclude": ["node_modules"] +}