├── .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 |
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 |

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 |

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 |
--------------------------------------------------------------------------------