├── .env.example ├── .gitignore ├── README.md ├── _eslintrc.cjs ├── next.config.mjs ├── package-lock.json ├── package.json ├── postcss.config.cjs ├── prettier.config.cjs ├── prisma └── schema.prisma ├── public └── favicon.ico ├── src ├── components │ ├── loading.tsx │ └── post-view.tsx ├── env.mjs ├── middleware.ts ├── pages │ ├── _app.tsx │ ├── api │ │ └── trpc │ │ │ └── [trpc].ts │ ├── index.tsx │ ├── post │ │ └── [id].tsx │ └── profile │ │ └── [slug].tsx ├── server │ ├── api │ │ ├── root.ts │ │ ├── routers │ │ │ ├── posts.ts │ │ │ └── profile.ts │ │ └── trpc.ts │ ├── db.ts │ └── ratelimit.ts ├── shared │ └── emojiValidator.ts ├── styles │ └── globals.css └── utils │ └── api.ts ├── tailwind.config.cjs └── tsconfig.json /.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 "/src/env.mjs" 10 | # should be updated accordingly. 11 | 12 | # Prisma 13 | # https://www.prisma.io/docs/reference/database-reference/connection-urls#env 14 | DATABASE_URL="file:./db.sqlite" 15 | -------------------------------------------------------------------------------- /.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 | 15 | # next.js 16 | /.next/ 17 | /out/ 18 | next-env.d.ts 19 | 20 | # production 21 | /build 22 | 23 | # misc 24 | .DS_Store 25 | *.pem 26 | 27 | # debug 28 | npm-debug.log* 29 | yarn-debug.log* 30 | yarn-error.log* 31 | .pnpm-debug.log* 32 | 33 | # local env files 34 | # 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 35 | .env 36 | .env*.local 37 | 38 | # vercel 39 | .vercel 40 | 41 | # typescript 42 | *.tsbuildinfo 43 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # A repo theo made for a tutorial 2 | 3 | Roast it pls it needs to be really good 4 | 5 | ### TODO 6 | 7 | - [x] Error handling in form 8 | - [x] Throw proper tRPC errors 9 | - [x] Empty state for profile view 10 | -------------------------------------------------------------------------------- /_eslintrc.cjs: -------------------------------------------------------------------------------- 1 | /** @type {import("eslint").Linter.Config} */ 2 | const config = { 3 | overrides: [ 4 | { 5 | extends: [ 6 | "plugin:@typescript-eslint/recommended-requiring-type-checking", 7 | ], 8 | files: ["*.ts", "*.tsx"], 9 | parserOptions: { 10 | project: "tsconfig.json", 11 | }, 12 | }, 13 | ], 14 | parser: "@typescript-eslint/parser", 15 | parserOptions: { 16 | project: "./tsconfig.json", 17 | }, 18 | plugins: ["@typescript-eslint"], 19 | extends: ["next/core-web-vitals", "plugin:@typescript-eslint/recommended"], 20 | rules: { 21 | "@typescript-eslint/consistent-type-imports": [ 22 | "warn", 23 | { 24 | prefer: "type-imports", 25 | fixStyle: "inline-type-imports", 26 | }, 27 | ], 28 | }, 29 | }; 30 | 31 | module.exports = config; 32 | -------------------------------------------------------------------------------- /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 | export default config; 25 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "emojer", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "build": "next build", 7 | "dev": "next dev", 8 | "postinstall": "prisma generate", 9 | "lint": "next lint", 10 | "start": "next start" 11 | }, 12 | "dependencies": { 13 | "@clerk/nextjs": "^4.11.1", 14 | "@prisma/client": "^4.9.0", 15 | "@tanstack/react-query": "^4.20.2", 16 | "@trpc/client": "^10.14.1", 17 | "@trpc/next": "^10.14.1", 18 | "@trpc/react-query": "^10.14.1", 19 | "@trpc/server": "^10.14.1", 20 | "@upstash/ratelimit": "^0.3.10", 21 | "dayjs": "^1.11.7", 22 | "next": "13.2.1", 23 | "react": "18.2.0", 24 | "react-dom": "18.2.0", 25 | "react-hot-toast": "^2.4.0", 26 | "superjson": "1.9.1", 27 | "zod": "^3.21.4" 28 | }, 29 | "devDependencies": { 30 | "@types/eslint": "^8.21.1", 31 | "@types/node": "^18.14.0", 32 | "@types/prettier": "^2.7.2", 33 | "@types/react": "^18.0.28", 34 | "@types/react-dom": "^18.0.11", 35 | "@typescript-eslint/eslint-plugin": "^5.53.0", 36 | "@typescript-eslint/parser": "^5.53.0", 37 | "autoprefixer": "^10.4.7", 38 | "eslint": "^8.34.0", 39 | "eslint-config-next": "13.1.6", 40 | "postcss": "^8.4.14", 41 | "prettier": "^2.8.1", 42 | "prettier-plugin-tailwindcss": "^0.2.1", 43 | "prisma": "^4.9.0", 44 | "tailwindcss": "^3.2.0", 45 | "typescript": "^4.9.5" 46 | }, 47 | "ct3aMetadata": { 48 | "initVersion": "7.5.7" 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /postcss.config.cjs: -------------------------------------------------------------------------------- 1 | const config = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | 8 | module.exports = config; 9 | -------------------------------------------------------------------------------- /prettier.config.cjs: -------------------------------------------------------------------------------- 1 | /** @type {import("prettier").Config} */ 2 | const config = { 3 | plugins: [require.resolve("prettier-plugin-tailwindcss")], 4 | }; 5 | 6 | module.exports = config; 7 | -------------------------------------------------------------------------------- /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 = "mysql" 10 | url = env("DATABASE_URL") 11 | relationMode = "prisma" 12 | } 13 | 14 | model Post { 15 | id String @id @default(cuid()) 16 | createdAt DateTime @default(now()) 17 | content String? @db.VarChar(255) 18 | published Boolean @default(false) 19 | authorId String 20 | 21 | // index by authorId 22 | @@index([authorId]) 23 | } 24 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/t3dotgg/emojer/fe82162948427fc7f3b2887885fb10f011b281d0/public/favicon.ico -------------------------------------------------------------------------------- /src/components/loading.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | const LoadingInternal = (props: { size?: number }) => { 4 | const size = props.size ?? "24"; 5 | return ( 6 | 13 | 14 | 15 | 16 | ); 17 | }; 18 | 19 | export const LoadingSpinner = React.memo(LoadingInternal); 20 | 21 | const LoadingPageInternal = () => ( 22 |
23 | 24 |
25 | ); 26 | 27 | export const LoadingPage = React.memo(LoadingPageInternal); 28 | -------------------------------------------------------------------------------- /src/components/post-view.tsx: -------------------------------------------------------------------------------- 1 | import dayjs from "dayjs"; 2 | import relativeTime from "dayjs/plugin/relativeTime"; 3 | import Link from "next/link"; 4 | import React from "react"; 5 | 6 | dayjs.extend(relativeTime); 7 | 8 | import { RouterOutputs } from "~/utils/api"; 9 | 10 | const Timestamp = (props: { createdAt: Date; link?: string }) => { 11 | const core = ( 12 | {` · ${dayjs( 13 | props.createdAt 14 | ).fromNow()}`} 15 | ); 16 | 17 | if (props.link) 18 | return ( 19 | 20 | {core} 21 | 22 | ); 23 | 24 | return core; 25 | }; 26 | 27 | type TweetData = RouterOutputs["posts"]["getAll"][number]; 28 | export const TweetView = React.memo( 29 | (props: { tweet: TweetData; noPostLink?: boolean }) => { 30 | return ( 31 |
32 | {!props.noPostLink && ( 33 | 37 | )} 38 |
39 | 43 | {`Profile 48 | 49 |
50 |
51 | 55 | {`@${props.tweet.user.username}`} 56 | 57 | 58 | 62 |
63 |
{props.tweet.content}
64 |
65 |
66 |
67 | ); 68 | } 69 | ); 70 | -------------------------------------------------------------------------------- /src/env.mjs: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | 3 | /** 4 | * Specify your server-side environment variables schema here. This way you can ensure the app isn't 5 | * 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 | UPSTASH_REDIS_REST_URL: z.string().url(), 11 | UPSTASH_REDIS_REST_TOKEN: z.string().min(1), 12 | }); 13 | 14 | /** 15 | * Specify your client-side environment variables schema here. This way you can ensure the app isn't 16 | * built with invalid env vars. To expose them to the client, prefix them with `NEXT_PUBLIC_`. 17 | */ 18 | const client = z.object({ 19 | // NEXT_PUBLIC_CLIENTVAR: z.string().min(1), 20 | }); 21 | 22 | /** 23 | * You can't destruct `process.env` as a regular object in the Next.js edge runtimes (e.g. 24 | * middlewares) or client-side so we need to destruct manually. 25 | * 26 | * @type {Record | keyof z.infer, string | undefined>} 27 | */ 28 | const processEnv = { 29 | DATABASE_URL: process.env.DATABASE_URL, 30 | NODE_ENV: process.env.NODE_ENV, 31 | UPSTASH_REDIS_REST_URL: process.env.UPSTASH_REDIS_REST_URL, 32 | UPSTASH_REDIS_REST_TOKEN: process.env.UPSTASH_REDIS_REST_TOKEN, 33 | // NEXT_PUBLIC_CLIENTVAR: process.env.NEXT_PUBLIC_CLIENTVAR, 34 | }; 35 | 36 | // Don't touch the part below 37 | // -------------------------- 38 | 39 | const merged = server.merge(client); 40 | 41 | /** @typedef {z.input} MergedInput */ 42 | /** @typedef {z.infer} MergedOutput */ 43 | /** @typedef {z.SafeParseReturnType} MergedSafeParseReturn */ 44 | 45 | let env = /** @type {MergedOutput} */ (process.env); 46 | 47 | if (!!process.env.SKIP_ENV_VALIDATION == false) { 48 | const isServer = typeof window === "undefined"; 49 | 50 | const parsed = /** @type {MergedSafeParseReturn} */ ( 51 | isServer 52 | ? merged.safeParse(processEnv) // on server we can validate all env vars 53 | : client.safeParse(processEnv) // on client we can only validate the ones that are exposed 54 | ); 55 | 56 | if (parsed.success === false) { 57 | console.error( 58 | "❌ Invalid environment variables:", 59 | parsed.error.flatten().fieldErrors 60 | ); 61 | throw new Error("Invalid environment variables"); 62 | } 63 | 64 | env = new Proxy(parsed.data, { 65 | get(target, prop) { 66 | if (typeof prop !== "string") return undefined; 67 | // Throw a descriptive error if a server-side env var is accessed on the client 68 | // Otherwise it would just be returning `undefined` and be annoying to debug 69 | if (!isServer && !prop.startsWith("NEXT_PUBLIC_")) 70 | throw new Error( 71 | process.env.NODE_ENV === "production" 72 | ? "❌ Attempted to access a server-side environment variable on the client" 73 | : `❌ Attempted to access server-side environment variable '${prop}' on the client` 74 | ); 75 | return target[/** @type {keyof typeof target} */ (prop)]; 76 | }, 77 | }); 78 | } 79 | 80 | export { env }; 81 | -------------------------------------------------------------------------------- /src/middleware.ts: -------------------------------------------------------------------------------- 1 | import { withClerkMiddleware } from "@clerk/nextjs/server"; 2 | import { NextResponse } from "next/server"; 3 | import type { NextRequest } from "next/server"; 4 | 5 | export default withClerkMiddleware((req: NextRequest) => { 6 | return NextResponse.next(); 7 | }); 8 | 9 | // Stop Middleware running on static files 10 | export const config = { 11 | matcher: "/((?!_next/image|_next/static|favicon.ico).*)", 12 | }; 13 | -------------------------------------------------------------------------------- /src/pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | ClerkProvider, 3 | SignedIn, 4 | SignedOut, 5 | RedirectToSignIn, 6 | } from "@clerk/nextjs"; 7 | import { AppProps } from "next/app"; 8 | import Head from "next/head"; 9 | import { useRouter } from "next/router"; 10 | import { Toaster } from "react-hot-toast"; 11 | import "~/styles/globals.css"; 12 | 13 | // List pages you want to be publicly accessible, or leave empty if 14 | // every page requires authentication. Use this naming strategy: 15 | // "/" for pages/index.js 16 | // "/foo" for pages/foo/index.js 17 | // "/foo/bar" for pages/foo/bar.js 18 | // "/foo/[...bar]" for pages/foo/[...bar].js 19 | const publicPages: Array = ["/", "/about"]; 20 | 21 | function MainApp({ Component, pageProps }: AppProps) { 22 | // Get the pathname 23 | const { pathname } = useRouter(); 24 | 25 | // Check if the current route matches a public page 26 | const isPublicPage = publicPages.includes(pathname); 27 | 28 | // If the current route is listed as public, render it directly 29 | // Otherwise, use Clerk to require authentication 30 | return ( 31 | 32 | 33 | 😶 Emojer 34 | 35 | 36 | 37 | {isPublicPage ? ( 38 | 39 | ) : ( 40 | <> 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | )} 49 | 50 | ); 51 | } 52 | 53 | // Wrap with tRPC 54 | import { api } from "~/utils/api"; 55 | export default api.withTRPC(MainApp); 56 | -------------------------------------------------------------------------------- /src/pages/api/trpc/[trpc].ts: -------------------------------------------------------------------------------- 1 | import { createNextApiHandler } from "@trpc/server/adapters/next"; 2 | 3 | import { env } from "~/env.mjs"; 4 | import { createTRPCContext } from "~/server/api/trpc"; 5 | import { appRouter } from "~/server/api/root"; 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 | -------------------------------------------------------------------------------- /src/pages/index.tsx: -------------------------------------------------------------------------------- 1 | import { useClerk, useUser } from "@clerk/nextjs"; 2 | import { type NextPage } from "next"; 3 | import Head from "next/head"; 4 | import { useState } from "react"; 5 | import dayjs from "dayjs"; 6 | import relativeTime from "dayjs/plugin/relativeTime"; 7 | 8 | dayjs.extend(relativeTime); 9 | 10 | import { api } from "~/utils/api"; 11 | import { TweetView } from "~/components/post-view"; 12 | import { LoadingPage } from "~/components/loading"; 13 | import Link from "next/link"; 14 | import { toast } from "react-hot-toast"; 15 | 16 | const CreatePostWizard = () => { 17 | const [content, setContent] = useState(""); 18 | 19 | const ctx = api.useContext(); 20 | const { mutate, isLoading } = api.posts.createPost.useMutation({ 21 | onSuccess: () => { 22 | setContent(""); 23 | ctx.invalidate(); 24 | }, 25 | onError: (e) => { 26 | // Custom message for emojis 27 | if (e.message.includes("emoji")) { 28 | toast.error(`PLEASE only post emojis <3`); 29 | return; 30 | } 31 | 32 | // Default 33 | toast.error(e.message); 34 | }, 35 | }); 36 | 37 | const { user } = useUser(); 38 | 39 | return ( 40 |
41 | 42 | Profile 47 | 48 | setContent(e.target.value)} 51 | onKeyDown={(e) => { 52 | if (e.key === "Enter") { 53 | e.preventDefault(); 54 | mutate({ message: content }); 55 | } 56 | }} 57 | disabled={isLoading} 58 | className="my-4 grow bg-transparent py-4 pr-20 text-xl outline-none" 59 | placeholder="Type some emojis" 60 | autoFocus 61 | /> 62 |
63 | {!!content && ( 64 | 76 | )} 77 |
78 |
79 | ); 80 | }; 81 | 82 | const CustomSignIn = () => { 83 | const { openSignIn } = useClerk(); 84 | 85 | return ( 86 |
87 | 88 |
89 | ); 90 | }; 91 | 92 | const Feed = () => { 93 | const { data, isLoading: postsLoading } = api.posts.getAll.useQuery(); 94 | const { isLoaded: userLoaded, user } = useUser(); 95 | 96 | if (postsLoading || !userLoaded) return ; 97 | 98 | return ( 99 |
100 | {!user && } 101 | {user && } 102 | {data?.map((post) => ( 103 | 104 | ))} 105 |
106 | ); 107 | }; 108 | 109 | const Home: NextPage = () => { 110 | return ( 111 | <> 112 | 113 | 😶 Emojer 114 | 115 | 116 |
117 | 118 |
119 | 120 | ); 121 | }; 122 | 123 | export default Home; 124 | -------------------------------------------------------------------------------- /src/pages/post/[id].tsx: -------------------------------------------------------------------------------- 1 | import { GetStaticProps, InferGetServerSidePropsType } from "next"; 2 | import Head from "next/head"; 3 | import dayjs from "dayjs"; 4 | import relativeTime from "dayjs/plugin/relativeTime"; 5 | 6 | dayjs.extend(relativeTime); 7 | 8 | import { api } from "~/utils/api"; 9 | 10 | const PostView = ( 11 | props: InferGetServerSidePropsType 12 | ) => { 13 | const { data } = api.posts.getPostById.useQuery({ id: props.id }); 14 | 15 | if (!data) { 16 | return
Not found
; 17 | } 18 | 19 | return ( 20 | <> 21 | 22 | {`${data.content} - @${data.user.username}`} 23 | 24 |
25 |
26 | 27 |
28 |
29 | 30 | ); 31 | }; 32 | 33 | export default PostView; 34 | 35 | import superjson from "superjson"; 36 | import { createProxySSGHelpers } from "@trpc/react-query/ssg"; 37 | import { appRouter } from "~/server/api/root"; 38 | import { prisma } from "~/server/db"; 39 | import { TweetView } from "~/components/post-view"; 40 | 41 | export const getStaticProps: GetStaticProps = async (context) => { 42 | const ssg = createProxySSGHelpers({ 43 | router: appRouter, 44 | ctx: { session: null, prisma }, 45 | transformer: superjson, 46 | }); 47 | const id = context.params?.id as string; 48 | 49 | await ssg.posts.getPostById.prefetch({ id }); 50 | // Make sure to return { props: { trpcState: ssg.dehydrate() } } 51 | return { 52 | props: { 53 | trpcState: ssg.dehydrate(), 54 | id, 55 | }, 56 | }; 57 | }; 58 | 59 | export async function getStaticPaths() { 60 | return { paths: [], fallback: "blocking" }; 61 | } 62 | -------------------------------------------------------------------------------- /src/pages/profile/[slug].tsx: -------------------------------------------------------------------------------- 1 | import { GetStaticProps, InferGetServerSidePropsType } from "next"; 2 | import Head from "next/head"; 3 | import dayjs from "dayjs"; 4 | import relativeTime from "dayjs/plugin/relativeTime"; 5 | 6 | dayjs.extend(relativeTime); 7 | 8 | import { api } from "~/utils/api"; 9 | 10 | const Feed = (props: { id: string }) => { 11 | const { data, isLoading } = api.posts.getPostsByUserId.useQuery({ 12 | id: props.id, 13 | }); 14 | 15 | if (isLoading) return ; 16 | 17 | return ( 18 | <> 19 | {data?.map((post) => ( 20 | 21 | ))} 22 | {data?.length === 0 && ( 23 |
24 | No posts on this profile 😔 25 |
26 | )} 27 | 28 | ); 29 | }; 30 | 31 | const ProfileView = ( 32 | props: InferGetServerSidePropsType 33 | ) => { 34 | const { data } = api.profile.getProfileByUsername.useQuery({ 35 | username: props.slug, 36 | }); 37 | console.log("data", data); 38 | 39 | if (!data) return
Not Found
; 40 | 41 | return ( 42 | <> 43 | 44 | {data.username}'s Profile 45 | 46 |
47 |
48 |
49 | 53 |
@{data.username}
54 | 55 |
56 |
57 | 58 | ); 59 | }; 60 | 61 | export default ProfileView; 62 | 63 | import superjson from "superjson"; 64 | import { createProxySSGHelpers } from "@trpc/react-query/ssg"; 65 | import { appRouter } from "~/server/api/root"; 66 | import { prisma } from "~/server/db"; 67 | import { TweetView } from "~/components/post-view"; 68 | import { LoadingPage } from "~/components/loading"; 69 | 70 | export const getStaticProps: GetStaticProps = async (context) => { 71 | const ssg = createProxySSGHelpers({ 72 | router: appRouter, 73 | ctx: { session: null, prisma }, 74 | transformer: superjson, 75 | }); 76 | const slug = context.params?.slug as string; 77 | 78 | await ssg.profile.getProfileByUsername.prefetch({ username: slug }); 79 | // Make sure to return { props: { trpcState: ssg.dehydrate() } } 80 | return { 81 | props: { 82 | trpcState: ssg.dehydrate(), 83 | slug, 84 | }, 85 | }; 86 | }; 87 | 88 | export async function getStaticPaths() { 89 | return { paths: [], fallback: "blocking" }; 90 | } 91 | -------------------------------------------------------------------------------- /src/server/api/root.ts: -------------------------------------------------------------------------------- 1 | import { createTRPCRouter } from "~/server/api/trpc"; 2 | import { postsRouter } from "~/server/api/routers/posts"; 3 | import { profileRouter } from "~/server/api/routers/profile"; 4 | 5 | /** 6 | * This is the primary router for your server. 7 | * 8 | * All routers added in /api/routers should be manually added here. 9 | */ 10 | export const appRouter = createTRPCRouter({ 11 | posts: postsRouter, 12 | profile: profileRouter, 13 | }); 14 | 15 | // export type definition of API 16 | export type AppRouter = typeof appRouter; 17 | -------------------------------------------------------------------------------- /src/server/api/routers/posts.ts: -------------------------------------------------------------------------------- 1 | import type { User as ClerkUser } from "@clerk/nextjs/dist/api"; 2 | import { clerkClient } from "@clerk/nextjs/server"; 3 | import { TRPCError } from "@trpc/server"; 4 | import { z } from "zod"; 5 | 6 | import { 7 | createTRPCRouter, 8 | protectedProcedure, 9 | publicProcedure, 10 | } from "~/server/api/trpc"; 11 | import { ratelimit } from "~/server/ratelimit"; 12 | import { emojiValidator } from "~/shared/emojiValidator"; 13 | 14 | const filterUser = (user: ClerkUser) => { 15 | return { 16 | id: user.id, 17 | username: user.username, 18 | profileImageUrl: user.profileImageUrl, 19 | }; 20 | }; 21 | 22 | export const postsRouter = createTRPCRouter({ 23 | getAll: publicProcedure.query(async ({ ctx }) => { 24 | const posts = await ctx.prisma.post.findMany({ 25 | orderBy: { createdAt: "desc" }, 26 | take: 100, 27 | }); 28 | 29 | const userIds = posts.map((post) => post.authorId); 30 | 31 | const users = await clerkClient.users 32 | .getUserList({ userId: userIds, limit: 100 }) 33 | .then((user) => user.map(filterUser)); 34 | 35 | return posts.map((post) => ({ 36 | ...post, 37 | user: users.find((user) => user.id === post.authorId)!, 38 | })); 39 | }), 40 | self: protectedProcedure.query(async ({ ctx }) => { 41 | return ctx.session; 42 | }), 43 | 44 | getPostById: publicProcedure 45 | .input(z.object({ id: z.string() })) 46 | .query(async ({ ctx, input }) => { 47 | const post = await ctx.prisma.post.findUnique({ 48 | where: { id: input.id }, 49 | }); 50 | 51 | if (!post) 52 | throw new TRPCError({ code: "NOT_FOUND", message: "Post not found" }); 53 | 54 | const user = await clerkClient.users 55 | .getUser(post!.authorId) 56 | .then(filterUser); 57 | 58 | if (!user) 59 | throw new TRPCError({ code: "NOT_FOUND", message: "User not found" }); 60 | 61 | return { ...post, user }; 62 | }), 63 | 64 | getPostsByUserId: publicProcedure 65 | .input(z.object({ id: z.string() })) 66 | .query(async ({ ctx, input }) => { 67 | const posts = await ctx.prisma.post.findMany({ 68 | where: { authorId: input.id }, 69 | orderBy: { createdAt: "desc" }, 70 | }); 71 | 72 | const userIds = posts.map((post) => post.authorId); 73 | 74 | const users = await clerkClient.users 75 | .getUserList({ userId: userIds }) 76 | .then((user) => user.map(filterUser)); 77 | 78 | if (posts.length === 0) 79 | throw new TRPCError({ 80 | code: "NOT_FOUND", 81 | message: "User has no posts", 82 | }); 83 | 84 | return posts.map((post) => ({ 85 | ...post, 86 | user: users.find((user) => user.id === post.authorId)!, 87 | })); 88 | }), 89 | 90 | createPost: protectedProcedure 91 | .input(emojiValidator) 92 | .mutation(async ({ ctx, input }) => { 93 | const { success } = await ratelimit.limit(ctx.session.userId); 94 | 95 | if (!success) 96 | throw new TRPCError({ 97 | code: "TOO_MANY_REQUESTS", 98 | message: "You've been posting too much. Chill a bit?", 99 | }); 100 | 101 | const post = await ctx.prisma.post.create({ 102 | data: { 103 | content: input.message, 104 | authorId: ctx.session.userId, 105 | }, 106 | }); 107 | 108 | return post; 109 | }), 110 | }); 111 | -------------------------------------------------------------------------------- /src/server/api/routers/profile.ts: -------------------------------------------------------------------------------- 1 | import type { User as ClerkUser } from "@clerk/nextjs/dist/api"; 2 | import { clerkClient } from "@clerk/nextjs/server"; 3 | import { TRPCError } from "@trpc/server"; 4 | import { z } from "zod"; 5 | 6 | import { 7 | createTRPCRouter, 8 | protectedProcedure, 9 | publicProcedure, 10 | } from "~/server/api/trpc"; 11 | 12 | const filterUser = (user: ClerkUser) => { 13 | return { 14 | id: user.id, 15 | username: user.username, 16 | profileImageUrl: user.profileImageUrl, 17 | }; 18 | }; 19 | 20 | export const profileRouter = createTRPCRouter({ 21 | self: protectedProcedure.query(async ({ ctx }) => { 22 | return ctx.session; 23 | }), 24 | 25 | getProfileById: publicProcedure 26 | .input(z.object({ id: z.string() })) 27 | .query(async ({ ctx, input }) => { 28 | const userPromise = clerkClient.users.getUser(input.id).then(filterUser); 29 | 30 | const postsPromise = ctx.prisma.post.findMany({ 31 | where: { authorId: input.id }, 32 | }); 33 | 34 | const user = await userPromise; 35 | const posts = await postsPromise; 36 | 37 | if (!user) 38 | throw new TRPCError({ code: "NOT_FOUND", message: "User not found" }); 39 | 40 | return { user, posts }; 41 | }), 42 | 43 | getProfileByUsername: publicProcedure 44 | .input(z.object({ username: z.string() })) 45 | .query(async ({ ctx, input }) => { 46 | const userPromise = clerkClient.users 47 | .getUserList({ username: [input.username] }) 48 | .then((m) => m.map(filterUser)); 49 | const [user] = await userPromise; 50 | 51 | if (!user) 52 | throw new TRPCError({ code: "NOT_FOUND", message: "User not found" }); 53 | 54 | return user; 55 | }), 56 | }); 57 | -------------------------------------------------------------------------------- /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. The pieces you will 7 | * 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 database, the session, etc. 16 | */ 17 | import { type CreateNextContextOptions } from "@trpc/server/adapters/next"; 18 | 19 | import { prisma } from "~/server/db"; 20 | 21 | import { getAuth } from "@clerk/nextjs/server"; 22 | /** 23 | * This is the actual context you will use in your router. It will be used to process every request 24 | * that goes through your tRPC endpoint. 25 | * 26 | * @see https://trpc.io/docs/context 27 | */ 28 | export const createTRPCContext = (opts: CreateNextContextOptions) => { 29 | const { req } = opts; 30 | const session = getAuth(req); 31 | 32 | // make session nullable so typing overrides isn't hellish 33 | return { session: session as ReturnType | null, prisma }; 34 | }; 35 | 36 | /** 37 | * 2. INITIALIZATION 38 | * 39 | * This is where the tRPC API is initialized, connecting the context and transformer. 40 | */ 41 | import { initTRPC, TRPCError } from "@trpc/server"; 42 | import superjson from "superjson"; 43 | 44 | const t = initTRPC.context().create({ 45 | transformer: superjson, 46 | errorFormatter({ shape }) { 47 | return shape; 48 | }, 49 | }); 50 | 51 | /** 52 | * 3. ROUTER & PROCEDURE (THE IMPORTANT BIT) 53 | * 54 | * These are the pieces you use to build your tRPC API. You should import these a lot in the 55 | * "/src/server/api/routers" directory. 56 | */ 57 | 58 | /** 59 | * This is how you create new routers and sub-routers in your tRPC API. 60 | * 61 | * @see https://trpc.io/docs/router 62 | */ 63 | export const createTRPCRouter = t.router; 64 | 65 | /** 66 | * Public (unauthenticated) procedure 67 | * 68 | * This is the base piece you use to build new queries and mutations on your tRPC API. It does not 69 | * guarantee that a user querying is authorized, but you can still access user session data if they 70 | * are logged in. 71 | */ 72 | export const publicProcedure = t.procedure; 73 | 74 | /** Reusable middleware that enforces users are logged in before running the procedure. */ 75 | const enforceUserIsAuthed = t.middleware(({ ctx, next }) => { 76 | if (!ctx.session || !ctx.session.userId) { 77 | throw new TRPCError({ code: "UNAUTHORIZED" }); 78 | } 79 | return next({ 80 | ctx: { 81 | // infers the `session` as non-nullable 82 | session: ctx.session, 83 | }, 84 | }); 85 | }); 86 | 87 | /** 88 | * Protected (authenticated) procedure 89 | * 90 | * If you want a query or mutation to ONLY be accessible to logged in users, use this. It verifies 91 | * the session is valid and guarantees `ctx.session.user` is not null. 92 | * 93 | * @see https://trpc.io/docs/procedures 94 | */ 95 | export const protectedProcedure = t.procedure.use(enforceUserIsAuthed); 96 | -------------------------------------------------------------------------------- /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/server/ratelimit.ts: -------------------------------------------------------------------------------- 1 | import { Ratelimit } from "@upstash/ratelimit"; 2 | import { Redis } from "@upstash/redis"; 3 | 4 | // Create a new ratelimiter, that allows 3 requests per minute 5 | export const ratelimit = new Ratelimit({ 6 | redis: Redis.fromEnv(), 7 | limiter: Ratelimit.slidingWindow(3, "60 s"), 8 | analytics: true, 9 | }); 10 | -------------------------------------------------------------------------------- /src/shared/emojiValidator.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | 3 | export const emojiValidator = z.object({ 4 | message: z.string().emoji("Emoji ONLY please <3"), 5 | }); 6 | -------------------------------------------------------------------------------- /src/styles/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | body { 6 | @apply bg-black; 7 | overscroll-behavior-y: none; 8 | } 9 | 10 | /* Loading spinner stuff */ 11 | .spinner_ZCsl { 12 | animation: spinner_qV4G 1.2s cubic-bezier(0.52, 0.6, 0.25, 0.99) infinite; 13 | } 14 | .spinner_gaIW { 15 | animation-delay: 0.6s; 16 | } 17 | @keyframes spinner_qV4G { 18 | 0% { 19 | r: 0; 20 | opacity: 1; 21 | } 22 | 100% { 23 | r: 11px; 24 | opacity: 0; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/utils/api.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This is the client-side entrypoint for your tRPC API. It is used to create the `api` object which 3 | * contains the Next.js App-wrapper, as well as your type-safe React Query hooks. 4 | * 5 | * We also create a few inference helpers for input and output types. 6 | */ 7 | import { httpBatchLink, loggerLink } from "@trpc/client"; 8 | import { createTRPCNext } from "@trpc/next"; 9 | import { type inferRouterInputs, type inferRouterOutputs } from "@trpc/server"; 10 | import superjson from "superjson"; 11 | 12 | import { type AppRouter } from "~/server/api/root"; 13 | 14 | const getBaseUrl = () => { 15 | if (typeof window !== "undefined") return ""; // browser should use relative url 16 | if (process.env.VERCEL_URL) return `https://${process.env.VERCEL_URL}`; // SSR should use vercel url 17 | return `http://localhost:${process.env.PORT ?? 3000}`; // dev SSR should use localhost 18 | }; 19 | 20 | /** A set of type-safe react-query hooks for your tRPC API. */ 21 | export const api = createTRPCNext({ 22 | config() { 23 | return { 24 | /** 25 | * Transformer used for data de-serialization from the server. 26 | * 27 | * @see https://trpc.io/docs/data-transformers 28 | */ 29 | transformer: superjson, 30 | 31 | /** 32 | * Links used to determine request flow from client to server. 33 | * 34 | * @see https://trpc.io/docs/links 35 | */ 36 | links: [ 37 | loggerLink({ 38 | enabled: (opts) => 39 | process.env.NODE_ENV === "development" || 40 | (opts.direction === "down" && opts.result instanceof Error), 41 | }), 42 | httpBatchLink({ 43 | url: `${getBaseUrl()}/api/trpc`, 44 | }), 45 | ], 46 | }; 47 | }, 48 | /** 49 | * Whether tRPC should await queries when server rendering pages. 50 | * 51 | * @see https://trpc.io/docs/nextjs#ssr-boolean-default-false 52 | */ 53 | ssr: false, 54 | }); 55 | 56 | /** 57 | * Inference helper for inputs. 58 | * 59 | * @example type HelloInput = RouterInputs['example']['hello'] 60 | */ 61 | export type RouterInputs = inferRouterInputs; 62 | 63 | /** 64 | * Inference helper for outputs. 65 | * 66 | * @example type HelloOutput = RouterOutputs['example']['hello'] 67 | */ 68 | export type RouterOutputs = inferRouterOutputs; 69 | -------------------------------------------------------------------------------- /tailwind.config.cjs: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | const config = { 3 | content: ["./src/**/*.{js,ts,jsx,tsx}"], 4 | theme: { 5 | extend: {}, 6 | }, 7 | plugins: [], 8 | }; 9 | 10 | module.exports = config; 11 | -------------------------------------------------------------------------------- /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 | ".eslintrc.cjs", 26 | "next-env.d.ts", 27 | "**/*.ts", 28 | "**/*.tsx", 29 | "**/*.cjs", 30 | "**/*.mjs" 31 | ], 32 | "exclude": ["node_modules"] 33 | } 34 | --------------------------------------------------------------------------------