├── .eslintrc.json ├── .gitignore ├── .vscode └── settings.json ├── README.md ├── next-env.d.ts ├── next.config.mjs ├── package.json ├── pnpm-lock.yaml ├── postcss.config.cjs ├── prisma ├── migrations │ ├── 20221014002322_image_size │ │ └── migration.sql │ └── migration_lock.toml └── schema.prisma ├── public ├── android-chrome-192x192.png ├── android-chrome-512x512.png ├── apple-touch-icon.png ├── favicon-16x16.png ├── favicon-32x32.png ├── favicon.ico └── site.webmanifest ├── src ├── app │ └── admin │ │ ├── layout.tsx │ │ └── page.tsx ├── components │ ├── profile-header.tsx │ └── profile-pic.tsx ├── env │ ├── client.mjs │ ├── schema.mjs │ └── server.mjs ├── middleware.ts ├── pages │ ├── _app.tsx │ ├── api │ │ ├── auth │ │ │ └── [...nextauth].ts │ │ ├── redirect │ │ │ └── [slug].ts │ │ ├── restricted.ts │ │ └── trpc │ │ │ └── [trpc].ts │ ├── image │ │ └── [slug].tsx │ ├── index.tsx │ └── profile │ │ ├── [id].tsx │ │ └── [id] │ │ └── settings.tsx ├── server │ ├── common │ │ └── get-server-auth-session.ts │ ├── db │ │ └── client.ts │ └── trpc │ │ ├── context.ts │ │ ├── router │ │ ├── _app.ts │ │ ├── images.ts │ │ └── user.ts │ │ └── trpc.ts ├── styles │ └── globals.css ├── types │ └── next-auth.d.ts └── utils │ ├── cx.ts │ └── trpc.ts ├── tailwind.config.cjs └── tsconfig.json /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "@typescript-eslint/parser", 3 | "parserOptions": { 4 | "project": "./tsconfig.json" 5 | }, 6 | "plugins": ["@typescript-eslint"], 7 | "extends": ["next/core-web-vitals", "plugin:@typescript-eslint/recommended"] 8 | } 9 | -------------------------------------------------------------------------------- /.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 | 19 | # production 20 | /build 21 | 22 | # misc 23 | .DS_Store 24 | *.pem 25 | 26 | # debug 27 | npm-debug.log* 28 | yarn-debug.log* 29 | yarn-error.log* 30 | .pnpm-debug.log* 31 | 32 | # local env files 33 | .env 34 | .env*.local 35 | 36 | # vercel 37 | .vercel 38 | 39 | # typescript 40 | *.tsbuildinfo 41 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "node_modules/typescript/lib", 3 | "typescript.enablePromptUseWorkspaceTsdk": true 4 | } 5 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Tincy Pics 2 | 3 | TODO: https://github.com/rodrigopivi/aws_resize_boilerplate 4 | -------------------------------------------------------------------------------- /next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | /// 4 | 5 | // NOTE: This file should not be edited 6 | // see https://nextjs.org/docs/basic-features/typescript for more information. 7 | -------------------------------------------------------------------------------- /next.config.mjs: -------------------------------------------------------------------------------- 1 | /** 2 | * Don't be scared of the generics here. 3 | * All they do is to give us autocompletion when using this. 4 | * 5 | * @template {import('next').NextConfig} T 6 | * @param {T} config - A generic parameter that flows through to the return type 7 | * @constraint {{import('next').NextConfig}} 8 | */ 9 | function defineNextConfig(config) { 10 | return config; 11 | } 12 | 13 | export default defineNextConfig({ 14 | reactStrictMode: true, 15 | swcMinify: true, 16 | // Next.js i18n docs: https://nextjs.org/docs/advanced-features/i18n-routing 17 | i18n: { 18 | locales: ["en"], 19 | defaultLocale: "en", 20 | }, 21 | }); 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tincypics", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint", 10 | "postinstall": "prisma generate", 11 | "format": "prettier --write ." 12 | }, 13 | "dependencies": { 14 | "@aws-sdk/client-s3": "^3.485.0", 15 | "@aws-sdk/s3-request-presigner": "^3.485.0", 16 | "@ianvs/prettier-plugin-sort-imports": "^4.1.1", 17 | "@next-auth/prisma-adapter": "^1.0.7", 18 | "@prisma/client": "^5.7.1", 19 | "@tanstack/react-query": "^4.36.1", 20 | "@trpc/client": "10.45.0", 21 | "@trpc/next": "10.45.0", 22 | "@trpc/react-query": "10.45.0", 23 | "@trpc/server": "10.45.0", 24 | "clsx": "^2.1.0", 25 | "cuid": "^3.0.0", 26 | "immer": "^10.0.3", 27 | "lucide-react": "^0.305.0", 28 | "next": "14.0.4", 29 | "next-auth": "~4.24.5", 30 | "react": "18.2.0", 31 | "react-dom": "18.2.0", 32 | "react-qr-code": "^2.0.12", 33 | "react-query": "3.39.3", 34 | "superjson": "^2.2.1", 35 | "tailwind-merge": "^2.2.0", 36 | "zod": "^3.22.4" 37 | }, 38 | "devDependencies": { 39 | "@types/node": "20.10.6", 40 | "@types/react": "18.2.46", 41 | "@types/react-dom": "18.2.18", 42 | "@typescript-eslint/eslint-plugin": "^6.17.0", 43 | "@typescript-eslint/parser": "^6.17.0", 44 | "autoprefixer": "^10.4.16", 45 | "eslint": "8.56.0", 46 | "eslint-config-next": "14.0.4", 47 | "postcss": "^8.4.33", 48 | "prettier": "3.1.1", 49 | "prettier-plugin-tailwindcss": "^0.5.10", 50 | "prisma": "^5.7.1", 51 | "tailwindcss": "^3.4.0", 52 | "typescript": "5.3.3" 53 | }, 54 | "ct3aMetadata": { 55 | "initVersion": "5.15.0" 56 | }, 57 | "prettier": { 58 | "plugins": [ 59 | "@ianvs/prettier-plugin-sort-imports", 60 | "prettier-plugin-tailwindcss" 61 | ], 62 | "importOrder": [ 63 | "^(react/(.*)$)|^(react$)|^(react-native(.*)$)", 64 | "^(next/(.*)$)|^(next$)", 65 | "", 66 | "", 67 | "^~/", 68 | "^[../]", 69 | "^[./]" 70 | ], 71 | "importOrderParserPlugins": [ 72 | "typescript", 73 | "jsx", 74 | "decorators-legacy" 75 | ], 76 | "importOrderTypeScriptVersion": "4.4.0" 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /postcss.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /prisma/migrations/20221014002322_image_size/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE "Image" ( 3 | "id" SERIAL NOT NULL, 4 | "slug" TEXT NOT NULL, 5 | "width" INTEGER, 6 | "height" INTEGER, 7 | "caption" TEXT, 8 | "userId" TEXT, 9 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 10 | "updatedAt" TIMESTAMP(3) NOT NULL, 11 | 12 | CONSTRAINT "Image_pkey" PRIMARY KEY ("id") 13 | ); 14 | 15 | -- CreateTable 16 | CREATE TABLE "Account" ( 17 | "id" TEXT NOT NULL, 18 | "userId" TEXT NOT NULL, 19 | "type" TEXT NOT NULL, 20 | "provider" TEXT NOT NULL, 21 | "providerAccountId" TEXT NOT NULL, 22 | "refresh_token" TEXT, 23 | "access_token" TEXT, 24 | "expires_at" INTEGER, 25 | "token_type" TEXT, 26 | "scope" TEXT, 27 | "id_token" TEXT, 28 | "session_state" TEXT, 29 | "refresh_token_expires_in" INTEGER, 30 | 31 | CONSTRAINT "Account_pkey" PRIMARY KEY ("id") 32 | ); 33 | 34 | -- CreateTable 35 | CREATE TABLE "Session" ( 36 | "id" TEXT NOT NULL, 37 | "sessionToken" TEXT NOT NULL, 38 | "userId" TEXT NOT NULL, 39 | "expires" TIMESTAMP(3) NOT NULL, 40 | 41 | CONSTRAINT "Session_pkey" PRIMARY KEY ("id") 42 | ); 43 | 44 | -- CreateTable 45 | CREATE TABLE "User" ( 46 | "id" TEXT NOT NULL, 47 | "name" TEXT, 48 | "email" TEXT, 49 | "emailVerified" TIMESTAMP(3), 50 | "image" TEXT, 51 | 52 | CONSTRAINT "User_pkey" PRIMARY KEY ("id") 53 | ); 54 | 55 | -- CreateTable 56 | CREATE TABLE "VerificationToken" ( 57 | "identifier" TEXT NOT NULL, 58 | "token" TEXT NOT NULL, 59 | "expires" TIMESTAMP(3) NOT NULL 60 | ); 61 | 62 | -- CreateIndex 63 | CREATE UNIQUE INDEX "Image_slug_key" ON "Image"("slug"); 64 | 65 | -- CreateIndex 66 | CREATE UNIQUE INDEX "Account_provider_providerAccountId_key" ON "Account"("provider", "providerAccountId"); 67 | 68 | -- CreateIndex 69 | CREATE UNIQUE INDEX "Session_sessionToken_key" ON "Session"("sessionToken"); 70 | 71 | -- CreateIndex 72 | CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); 73 | 74 | -- CreateIndex 75 | CREATE UNIQUE INDEX "VerificationToken_token_key" ON "VerificationToken"("token"); 76 | 77 | -- CreateIndex 78 | CREATE UNIQUE INDEX "VerificationToken_identifier_token_key" ON "VerificationToken"("identifier", "token"); 79 | 80 | -- AddForeignKey 81 | ALTER TABLE "Image" ADD CONSTRAINT "Image_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; 82 | 83 | -- AddForeignKey 84 | ALTER TABLE "Account" ADD CONSTRAINT "Account_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; 85 | 86 | -- AddForeignKey 87 | ALTER TABLE "Session" ADD CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; 88 | -------------------------------------------------------------------------------- /prisma/migrations/migration_lock.toml: -------------------------------------------------------------------------------- 1 | # Please do not edit this file manually 2 | # It should be added in your version-control system (i.e. Git) 3 | provider = "postgresql" -------------------------------------------------------------------------------- /prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | generator client { 2 | provider = "prisma-client-js" 3 | } 4 | 5 | datasource db { 6 | provider = "postgresql" 7 | url = env("DATABASE_URL") 8 | } 9 | 10 | // -------------------------------------------- 11 | 12 | model Image { 13 | id Int @id @default(autoincrement()) 14 | slug String @unique 15 | width Int? 16 | height Int? 17 | caption String? 18 | user User? @relation(fields: [userId], references: [id]) 19 | userId String? 20 | createdAt DateTime @default(now()) 21 | updatedAt DateTime @updatedAt 22 | } 23 | 24 | // ----------------- NEXTAUTH ----------------- 25 | 26 | model Account { 27 | id String @id @default(cuid()) 28 | userId String 29 | type String 30 | provider String 31 | providerAccountId String 32 | refresh_token String? 33 | access_token String? 34 | expires_at Int? 35 | token_type String? 36 | scope String? 37 | id_token String? 38 | session_state String? 39 | refresh_token_expires_in Int? 40 | user User @relation(fields: [userId], references: [id], onDelete: Cascade) 41 | 42 | @@unique([provider, providerAccountId]) 43 | } 44 | 45 | model Session { 46 | id String @id @default(cuid()) 47 | sessionToken String @unique 48 | userId String 49 | expires DateTime 50 | user User @relation(fields: [userId], references: [id], onDelete: Cascade) 51 | } 52 | 53 | model User { 54 | id String @id @default(cuid()) 55 | name String? 56 | email String? @unique 57 | emailVerified DateTime? 58 | image String? 59 | role UserRole @default(USER) 60 | accounts Account[] 61 | sessions Session[] 62 | images Image[] 63 | } 64 | 65 | enum UserRole { 66 | ADMIN 67 | USER 68 | } 69 | 70 | model VerificationToken { 71 | identifier String 72 | token String @unique 73 | expires DateTime 74 | 75 | @@unique([identifier, token]) 76 | } 77 | 78 | // -------------------------------------------- 79 | -------------------------------------------------------------------------------- /public/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mozzius/tincypics/8f805a698a53cbcf8ec6bc7068f3d11a627a405a/public/android-chrome-192x192.png -------------------------------------------------------------------------------- /public/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mozzius/tincypics/8f805a698a53cbcf8ec6bc7068f3d11a627a405a/public/android-chrome-512x512.png -------------------------------------------------------------------------------- /public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mozzius/tincypics/8f805a698a53cbcf8ec6bc7068f3d11a627a405a/public/apple-touch-icon.png -------------------------------------------------------------------------------- /public/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mozzius/tincypics/8f805a698a53cbcf8ec6bc7068f3d11a627a405a/public/favicon-16x16.png -------------------------------------------------------------------------------- /public/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mozzius/tincypics/8f805a698a53cbcf8ec6bc7068f3d11a627a405a/public/favicon-32x32.png -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mozzius/tincypics/8f805a698a53cbcf8ec6bc7068f3d11a627a405a/public/favicon.ico -------------------------------------------------------------------------------- /public/site.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "", 3 | "short_name": "", 4 | "icons": [ 5 | { 6 | "src": "/android-chrome-192x192.png", 7 | "sizes": "192x192", 8 | "type": "image/png" 9 | }, 10 | { 11 | "src": "/android-chrome-512x512.png", 12 | "sizes": "512x512", 13 | "type": "image/png" 14 | } 15 | ], 16 | "theme_color": "#ffffff", 17 | "background_color": "#ffffff", 18 | "display": "standalone" 19 | } 20 | -------------------------------------------------------------------------------- /src/app/admin/layout.tsx: -------------------------------------------------------------------------------- 1 | import { getServerSession } from "next-auth"; 2 | 3 | import { authOptions } from "../../pages/api/auth/[...nextauth]"; 4 | import { prisma } from "../../server/db/client"; 5 | 6 | import "../../styles/globals.css"; 7 | 8 | import { redirect } from "next/navigation"; 9 | import { UserRole } from "@prisma/client"; 10 | 11 | export default async function AdminLayout({ 12 | children, 13 | }: { 14 | children: React.ReactNode; 15 | }) { 16 | const session = await getServerSession(authOptions); 17 | 18 | if (!session || !session.user) { 19 | redirect("/"); 20 | } 21 | 22 | const details = await prisma.user.findUnique({ 23 | where: { id: session.user.id }, 24 | }); 25 | 26 | if (!details || details.role !== UserRole.ADMIN) { 27 | redirect("/"); 28 | } 29 | 30 | return ( 31 | 32 | 33 | 34 |
35 |
36 | {children} 37 |
38 |
39 | 40 | 41 | ); 42 | } 43 | -------------------------------------------------------------------------------- /src/app/admin/page.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | 3 | import { prisma } from "../../server/db/client"; 4 | 5 | export default async function AdminPage() { 6 | const imageCount = await prisma.image.aggregate({ 7 | _count: true, 8 | }); 9 | const users = await prisma.user.findMany({ 10 | include: { 11 | _count: { 12 | select: { 13 | images: true, 14 | }, 15 | }, 16 | }, 17 | orderBy: { 18 | images: { 19 | _count: "desc", 20 | }, 21 | }, 22 | }); 23 | 24 | const totalImages = users.reduce( 25 | (total, user) => total + user._count.images, 26 | 0, 27 | ); 28 | 29 | return ( 30 | <> 31 |

Admin Page

32 |

33 | There are {imageCount._count} images in the database. 34 |

35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | {users.map((user) => ( 45 | 46 | 51 | 52 | 53 | 54 | ))} 55 | 56 | 59 | 60 | 61 | 62 | 65 | 66 | 67 | 68 |
NameEmailImages
47 | 48 | {user.name} 49 | 50 | {user.email}{user._count.images}
57 | Total Users 58 | {users.length}
63 | Total Images 64 | {totalImages}
69 | 70 | ); 71 | } 72 | -------------------------------------------------------------------------------- /src/components/profile-header.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @next/next/no-img-element */ 2 | 3 | import Link from "next/link"; 4 | import { Image, User } from "@prisma/client"; 5 | import { ChevronLeft } from "lucide-react"; 6 | 7 | interface Props { 8 | user?: User & { images: Image[] }; 9 | } 10 | 11 | export const ProfileHeader = ({ user }: Props) => { 12 | return ( 13 |
14 | 18 | 19 | 20 | {user ? ( 21 | <> 22 | {user.name 27 |
28 |

29 | {user.images.length} pics uploaded 30 |

31 |

{user.name}

32 |
33 | 34 | ) : ( 35 | <> 36 |
37 |
38 |
39 |
40 |
41 | 42 | )} 43 |
44 | ); 45 | }; 46 | -------------------------------------------------------------------------------- /src/components/profile-pic.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @next/next/no-img-element */ 2 | import Link from "next/link"; 3 | import { useSession } from "next-auth/react"; 4 | 5 | export const ProfilePic = () => { 6 | const { data, status } = useSession(); 7 | 8 | if (status !== "authenticated" || !data.user) return null; 9 | 10 | const image = data.user?.image ?? ""; 11 | const name = data.user?.name ?? ""; 12 | 13 | return ( 14 | 21 | {name} 22 | 23 | ); 24 | }; 25 | -------------------------------------------------------------------------------- /src/env/client.mjs: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | import { clientEnv, clientSchema } from "./schema.mjs"; 3 | 4 | const _clientEnv = clientSchema.safeParse(clientEnv); 5 | 6 | export const formatErrors = ( 7 | /** @type {import('zod').ZodFormattedError,string>} */ 8 | errors, 9 | ) => 10 | Object.entries(errors) 11 | .map(([name, value]) => { 12 | if (value && "_errors" in value) 13 | return `${name}: ${value._errors.join(", ")}\n`; 14 | }) 15 | .filter(Boolean); 16 | 17 | if (_clientEnv.success === false) { 18 | console.error( 19 | "❌ Invalid environment variables:\n", 20 | ...formatErrors(_clientEnv.error.format()), 21 | ); 22 | throw new Error("Invalid environment variables"); 23 | } 24 | 25 | /** 26 | * Validate that client-side environment variables are exposed to the client. 27 | */ 28 | for (let key of Object.keys(_clientEnv.data)) { 29 | if (!key.startsWith("NEXT_PUBLIC_")) { 30 | console.warn( 31 | `❌ Invalid public environment variable name: ${key}. It must begin with 'NEXT_PUBLIC_'`, 32 | ); 33 | 34 | throw new Error("Invalid public environment variable name"); 35 | } 36 | } 37 | 38 | export const env = _clientEnv.data; 39 | -------------------------------------------------------------------------------- /src/env/schema.mjs: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | import { z } from "zod"; 3 | 4 | /** 5 | * Specify your server-side environment variables schema here. 6 | * This way you can ensure the app isn't built with invalid env vars. 7 | */ 8 | export const serverSchema = z.object({ 9 | DATABASE_URL: z.string().url(), 10 | NODE_ENV: z.enum(["development", "test", "production"]), 11 | NEXTAUTH_SECRET: z.string(), 12 | NEXTAUTH_URL: z.string().url(), 13 | GITHUB_CLIENT_ID: z.string(), 14 | GITHUB_CLIENT_SECRET: z.string(), 15 | UPLOAD_AWS_REGION: z.string(), 16 | UPLOAD_AWS_ACCESS_KEY_ID: z.string(), 17 | UPLOAD_AWS_SECRET_ACCESS_KEY: z.string(), 18 | UPLOAD_AWS_S3_BUCKET: z.string(), 19 | }); 20 | 21 | /** 22 | * Specify your client-side environment variables schema here. 23 | * This way you can ensure the app isn't built with invalid env vars. 24 | * To expose them to the client, prefix them with `NEXT_PUBLIC_`. 25 | */ 26 | export const clientSchema = z.object({ 27 | // NEXT_PUBLIC_BAR: z.string(), 28 | }); 29 | 30 | /** 31 | * You can't destruct `process.env` as a regular object, so you have to do 32 | * it manually here. This is because Next.js evaluates this at build time, 33 | * and only used environment variables are included in the build. 34 | * @type {{ [k in keyof z.infer]: z.infer[k] | undefined }} 35 | */ 36 | export const clientEnv = { 37 | // NEXT_PUBLIC_BAR: process.env.NEXT_PUBLIC_BAR, 38 | }; 39 | -------------------------------------------------------------------------------- /src/env/server.mjs: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | /** 3 | * This file is included in `/next.config.mjs` which ensures the app isn't built with invalid env vars. 4 | * It has to be a `.mjs`-file to be imported there. 5 | */ 6 | import { env as clientEnv, formatErrors } from "./client.mjs"; 7 | import { serverSchema } from "./schema.mjs"; 8 | 9 | const _serverEnv = serverSchema.safeParse(process.env); 10 | 11 | if (_serverEnv.success === false) { 12 | console.error( 13 | "❌ Invalid environment variables:\n", 14 | ...formatErrors(_serverEnv.error.format()), 15 | ); 16 | throw new Error("Invalid environment variables"); 17 | } 18 | 19 | /** 20 | * Validate that server-side environment variables are not exposed to the client. 21 | */ 22 | for (let key of Object.keys(_serverEnv.data)) { 23 | if (key.startsWith("NEXT_PUBLIC_")) { 24 | console.warn("❌ You are exposing a server-side env-variable:", key); 25 | 26 | throw new Error("You are exposing a server-side env-variable"); 27 | } 28 | } 29 | 30 | export const env = { ..._serverEnv.data, ...clientEnv }; 31 | -------------------------------------------------------------------------------- /src/middleware.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from "next/server"; 2 | import type { NextRequest } from "next/server"; 3 | 4 | export async function middleware(request: NextRequest) { 5 | const slug = request.nextUrl.pathname; 6 | 7 | console.log("[middleware]", slug); 8 | 9 | if (slug !== "/") { 10 | const redirect = request.nextUrl.clone(); 11 | redirect.pathname = `/api/redirect${slug}`; 12 | console.log(`[middleware] redirecting to ${redirect.pathname}`); 13 | return NextResponse.rewrite(redirect); 14 | } 15 | 16 | return NextResponse.next(); 17 | } 18 | 19 | // See "Matching Paths" below to learn more 20 | export const config = { 21 | matcher: "/i/:slug?", 22 | }; 23 | -------------------------------------------------------------------------------- /src/pages/_app.tsx: -------------------------------------------------------------------------------- 1 | // src/pages/_app.tsx 2 | import type { AppType } from "next/app"; 3 | import { httpBatchLink } from "@trpc/client/links/httpBatchLink"; 4 | import { loggerLink } from "@trpc/client/links/loggerLink"; 5 | import { withTRPC } from "@trpc/next"; 6 | import type { Session } from "next-auth"; 7 | import { SessionProvider } from "next-auth/react"; 8 | import superjson from "superjson"; 9 | 10 | import "../styles/globals.css"; 11 | 12 | import Head from "next/head"; 13 | 14 | import { AppRouter } from "../server/trpc/router/_app"; 15 | 16 | const MyApp: AppType<{ session: Session | null }> = ({ 17 | Component, 18 | pageProps: { session, ...pageProps }, 19 | }) => { 20 | return ( 21 | 22 | 23 | Tincy Pics 24 | 25 | 30 | 36 | 42 | 43 | 44 | 45 | 46 | ); 47 | }; 48 | 49 | const getBaseUrl = () => { 50 | if (typeof window !== "undefined") return ""; // browser should use relative url 51 | if (process.env.VERCEL_URL) return `https://${process.env.VERCEL_URL}`; // SSR should use vercel url 52 | return `http://localhost:${process.env.PORT ?? 3000}`; // dev SSR should use localhost 53 | }; 54 | 55 | export default withTRPC({ 56 | config({}) { 57 | // { ctx } 58 | /** 59 | * If you want to use SSR, you need to use the server's full URL 60 | * @link https://trpc.io/docs/ssr 61 | */ 62 | const url = `${getBaseUrl()}/api/trpc`; 63 | 64 | return { 65 | links: [ 66 | loggerLink({ 67 | enabled: (opts) => 68 | process.env.NODE_ENV === "development" || 69 | (opts.direction === "down" && opts.result instanceof Error), 70 | }), 71 | httpBatchLink({ url }), 72 | ], 73 | url, 74 | transformer: superjson, 75 | /** 76 | * @link https://react-query.tanstack.com/reference/QueryClient 77 | */ 78 | // queryClientConfig: { defaultOptions: { queries: { staleTime: 60 } } }, 79 | 80 | // To use SSR properly you need to forward the client's headers to the server 81 | // headers: () => { 82 | // if (ctx?.req) { 83 | // const headers = ctx?.req?.headers; 84 | // delete headers?.connection; 85 | // return { 86 | // ...headers, 87 | // "x-ssr": "1", 88 | // }; 89 | // } 90 | // return {}; 91 | // } 92 | }; 93 | }, 94 | /** 95 | * @link https://trpc.io/docs/ssr 96 | */ 97 | ssr: true, 98 | })(MyApp); 99 | -------------------------------------------------------------------------------- /src/pages/api/auth/[...nextauth].ts: -------------------------------------------------------------------------------- 1 | // Prisma adapter for NextAuth, optional and can be removed 2 | import { PrismaAdapter } from "@next-auth/prisma-adapter"; 3 | import NextAuth, { type NextAuthOptions } from "next-auth"; 4 | import GithubProvider from "next-auth/providers/github"; 5 | 6 | import { env } from "../../../env/server.mjs"; 7 | import { prisma } from "../../../server/db/client"; 8 | 9 | export const authOptions: NextAuthOptions = { 10 | // Include user.id on session 11 | callbacks: { 12 | session({ session, user }) { 13 | if (session.user) { 14 | session.user.id = user.id; 15 | } 16 | return session; 17 | }, 18 | }, 19 | // Configure one or more authentication providers 20 | adapter: PrismaAdapter(prisma), 21 | providers: [ 22 | GithubProvider({ 23 | clientId: env.GITHUB_CLIENT_ID, 24 | clientSecret: env.GITHUB_CLIENT_SECRET, 25 | }), 26 | // ...add more providers here 27 | ], 28 | }; 29 | 30 | export default NextAuth(authOptions); 31 | -------------------------------------------------------------------------------- /src/pages/api/redirect/[slug].ts: -------------------------------------------------------------------------------- 1 | import type { NextApiRequest, NextApiResponse } from "next"; 2 | 3 | import { prisma } from "../../../server/db/client"; 4 | 5 | export default async function redirectRoute( 6 | req: NextApiRequest, 7 | res: NextApiResponse, 8 | ) { 9 | const slug = req.query.slug as string; 10 | 11 | if (!slug) { 12 | res.status(404).end(); 13 | return; 14 | } 15 | 16 | const link = await prisma.image.findUnique({ 17 | where: { slug }, 18 | }); 19 | 20 | if (!link) { 21 | res.status(404).end(); 22 | return; 23 | } 24 | 25 | // cache for as long as possible 26 | res.setHeader("Cache-Control", "public, max-age=31536000, immutable"); 27 | 28 | res.redirect(`https://i.tincy.pics${link.slug}`); 29 | } 30 | -------------------------------------------------------------------------------- /src/pages/api/restricted.ts: -------------------------------------------------------------------------------- 1 | // Example of a restricted endpoint that only authenticated users can access from https://next-auth.js.org/getting-started/example 2 | 3 | import { NextApiRequest, NextApiResponse } from "next"; 4 | 5 | import { getServerAuthSession } from "../../server/common/get-server-auth-session"; 6 | 7 | const restricted = async (req: NextApiRequest, res: NextApiResponse) => { 8 | const session = await getServerAuthSession({ req, res }); 9 | 10 | if (session) { 11 | res.send({ 12 | content: 13 | "This is protected content. You can access this content because you are signed in.", 14 | }); 15 | } else { 16 | res.send({ 17 | error: 18 | "You must be signed in to view the protected content on this page.", 19 | }); 20 | } 21 | }; 22 | 23 | export default restricted; 24 | -------------------------------------------------------------------------------- /src/pages/api/trpc/[trpc].ts: -------------------------------------------------------------------------------- 1 | // src/pages/api/trpc/[trpc].ts 2 | import { createNextApiHandler } from "@trpc/server/adapters/next"; 3 | 4 | import { env } from "../../../env/server.mjs"; 5 | import { createContext } from "../../../server/trpc/context"; 6 | import { appRouter } from "../../../server/trpc/router/_app"; 7 | 8 | // export API handler 9 | export default createNextApiHandler({ 10 | router: appRouter, 11 | createContext, 12 | onError: 13 | env.NODE_ENV === "development" 14 | ? ({ path, error }) => { 15 | console.error(`❌ tRPC failed on ${path}: ${error}`); 16 | } 17 | : undefined, 18 | }); 19 | -------------------------------------------------------------------------------- /src/pages/image/[slug].tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @next/next/no-img-element */ 2 | import { useState } from "react"; 3 | import { NextPage } from "next"; 4 | import Link from "next/link"; 5 | import { useRouter } from "next/router"; 6 | import { Check, ChevronLeft, Copy, Trash } from "lucide-react"; 7 | import { useSession } from "next-auth/react"; 8 | 9 | import { trpc } from "../../utils/trpc"; 10 | 11 | const ImagePage: NextPage = () => { 12 | const { query, push } = useRouter(); 13 | const [copied, setCopied] = useState(false); 14 | const image = trpc.images.get.useQuery(query.slug as string, { 15 | enabled: !!query.slug, 16 | }); 17 | const deleteImg = trpc.images.delete.useMutation({ 18 | onSuccess: ({ userId }) => { 19 | if (userId) { 20 | push(`/profile/${userId}`); 21 | } else { 22 | push(`/`); 23 | } 24 | }, 25 | }); 26 | const session = useSession(); 27 | 28 | const isMe = session.data?.user?.id === image.data?.userId; 29 | 30 | if (image.data === null) { 31 | return
Not found
; 32 | } 33 | 34 | if (!image.data) return null; 35 | 36 | return ( 37 |
38 |
39 | 43 | 44 | 45 |
46 | {image.data?.caption 51 |
52 |
53 | {image.data.user && ( 54 | 58 | {image.data.user.image && ( 59 | {image.data.user.name 64 | )} 65 |

{image.data.user.name}

66 | 67 | )} 68 |
69 | 80 | {isMe && ( 81 | 88 | )} 89 |
90 |
91 |
92 |
93 | ); 94 | }; 95 | 96 | export default ImagePage; 97 | -------------------------------------------------------------------------------- /src/pages/index.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @next/next/no-img-element */ 2 | import { useState } from "react"; 3 | import type { NextPage } from "next"; 4 | import cuid from "cuid"; 5 | import { produce } from "immer"; 6 | import { Check, Copy, Github, Loader2, X } from "lucide-react"; 7 | import { signIn, useSession } from "next-auth/react"; 8 | import QRCode from "react-qr-code"; 9 | 10 | import { ProfilePic } from "../components/profile-pic"; 11 | import { cx } from "../utils/cx"; 12 | import { trpc } from "../utils/trpc"; 13 | 14 | type Preview = { 15 | id: string; 16 | src: string; 17 | done: boolean; 18 | }; 19 | 20 | const Home: NextPage = () => { 21 | const [isDragging, setIsDragging] = useState(false); 22 | const [previews, setPreviews] = useState([]); 23 | const upload = trpc.images.upload.useMutation(); 24 | const deleteImage = trpc.images.deleteBySlug.useMutation(); 25 | const session = useSession(); 26 | 27 | const preventDefaults = (e: React.DragEvent) => { 28 | e.preventDefault(); 29 | e.stopPropagation(); 30 | }; 31 | 32 | const onDrogFile = async (evt: React.DragEvent) => { 33 | preventDefaults(evt); 34 | 35 | const files = [...evt.dataTransfer.files].filter((file) => 36 | file.type.startsWith("image/"), 37 | ); 38 | 39 | if (files.length) { 40 | uploadFiles(files); 41 | } else { 42 | setIsDragging(false); 43 | } 44 | }; 45 | 46 | const onSelectFiles = async (evt: React.ChangeEvent) => { 47 | evt.preventDefault(); 48 | 49 | const files = [...(evt.target.files ?? [])].filter((file) => 50 | file.type.startsWith("image/"), 51 | ); 52 | if (files.length) { 53 | uploadFiles(files); 54 | } 55 | }; 56 | 57 | const uploadFiles = async (files: File[]) => { 58 | setIsDragging(true); 59 | // 1. generate id 60 | // 2. read file and set preview 61 | // 3. find width and height 62 | const images = await Promise.all( 63 | Array.from({ length: files.length }, async (_, i) => { 64 | const id = cuid(); 65 | const file = files[i]; 66 | const { width, height } = await new Promise<{ 67 | width: number; 68 | height: number; 69 | }>((resolve) => { 70 | const reader = new FileReader(); 71 | reader.onload = (e) => { 72 | const src = e.target?.result as string; 73 | setPreviews( 74 | produce((draft) => { 75 | draft.push({ id, src, done: false }); 76 | }), 77 | ); 78 | const img = new Image(); 79 | img.onload = () => 80 | resolve({ width: img.width, height: img.height }); 81 | img.src = src; 82 | }; 83 | reader.readAsDataURL(file); 84 | }); 85 | return { slug: id, width, height }; 86 | }), 87 | ); 88 | for (let i = 0; i < files.length; i++) {} 89 | const uploadUrls = await upload.mutateAsync(images); 90 | for (let i = 0; i < files.length; i++) { 91 | const file = files[i]; 92 | const upload = uploadUrls[i]; 93 | try { 94 | const res = await fetch(upload.url, { 95 | method: "PUT", 96 | body: file, 97 | }); 98 | if (!res.ok) throw Error(res.statusText); 99 | setPreviews( 100 | produce((draft) => { 101 | const index = draft.findIndex((p) => p.id === images[i].slug); 102 | if (index > -1) draft[index].done = true; 103 | }), 104 | ); 105 | } catch (err) { 106 | console.error(err); 107 | deleteImage.mutate(upload.slug); 108 | setPreviews( 109 | produce((draft) => { 110 | const index = draft.findIndex((p) => p.id === images[i].slug); 111 | if (index > -1) draft.splice(index, 1); 112 | }), 113 | ); 114 | alert("Upload failed"); 115 | break; 116 | } 117 | } 118 | }; 119 | 120 | return ( 121 |
130 | 131 |
132 |

137 | tincy.pics 138 |

139 |
140 |
{ 145 | preventDefaults(evt); 146 | setIsDragging( 147 | evt.dataTransfer.items && evt.dataTransfer.items.length > 0, 148 | ); 149 | }} 150 | onDragLeave={(evt) => { 151 | preventDefaults(evt); 152 | setIsDragging(false); 153 | }} 154 | > 155 |

156 | Drag and drop your images here 157 | 166 |

167 |
168 |
169 | 170 | 181 |
182 | {previews.length > 0 && ( 183 |
184 | { 187 | setPreviews([]); 188 | setIsDragging(false); 189 | }} 190 | /> 191 | {previews.map((preview) => ( 192 | 193 | ))} 194 |
195 | )} 196 |
197 | ); 198 | }; 199 | 200 | export default Home; 201 | 202 | const Preview = ({ id, src, done }: Preview) => { 203 | const [copied, setCopied] = useState(false); 204 | 205 | return ( 206 |
207 | 208 |
209 |
210 | 215 |
216 |
{ 219 | setCopied(true); 220 | navigator.clipboard.writeText(`https://i.tincy.pics/${id}`); 221 | }} 222 | > 223 | https://i.tincy.pics/{id} 224 | {copied ? ( 225 | 226 | ) : done ? ( 227 | 228 | ) : ( 229 | 230 | )} 231 |
232 |
233 |
234 | ); 235 | }; 236 | -------------------------------------------------------------------------------- /src/pages/profile/[id].tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @next/next/no-img-element */ 2 | import { NextPage } from "next"; 3 | import Link from "next/link"; 4 | import { useRouter } from "next/router"; 5 | import { useSession } from "next-auth/react"; 6 | 7 | import { ProfileHeader } from "../../components/profile-header"; 8 | import { trpc } from "../../utils/trpc"; 9 | 10 | const ProfilePage: NextPage = () => { 11 | const { query } = useRouter(); 12 | const profile = trpc.user.profile.useQuery(query.id as string, { 13 | enabled: !!query.id, 14 | }); 15 | const session = useSession(); 16 | 17 | const isMe = session.data?.user?.id === query.id; 18 | 19 | if (profile.data === null) { 20 | return
Not found
; 21 | } 22 | 23 | return ( 24 |
25 | 26 |
27 | 49 |
50 | {!profile.data ? ( 51 | Array.from({ length: 6 }, (_, i) => ( 52 |
56 | )) 57 | ) : profile.data.images.length === 0 ? ( 58 |

No pics yet!

59 | ) : ( 60 | profile.data.images.map((image) => ( 61 | 66 | 71 | 72 | )) 73 | )} 74 |
75 |
76 |
77 | ); 78 | }; 79 | 80 | export default ProfilePage; 81 | 82 | // copy/delete buttons 83 | 84 | /* 85 |
86 |
89 | navigator.clipboard.writeText( 90 | `https://i.tincy.pics/${image.id}` 91 | ) 92 | } 93 | > 94 | 95 |
96 |
deleteImg.mutate({ id: image.id })} 99 | > 100 | 101 |
102 |
103 | */ 104 | -------------------------------------------------------------------------------- /src/pages/profile/[id]/settings.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @next/next/no-img-element */ 2 | import { useEffect } from "react"; 3 | import { NextPage } from "next"; 4 | import Link from "next/link"; 5 | import { useRouter } from "next/router"; 6 | import { signOut, useSession } from "next-auth/react"; 7 | 8 | import { ProfileHeader } from "../../../components/profile-header"; 9 | import { trpc } from "../../../utils/trpc"; 10 | 11 | const ProfilePage: NextPage = () => { 12 | const { query, replace } = useRouter(); 13 | const profile = trpc.user.profile.useQuery(query.id as string, { 14 | enabled: !!query.id, 15 | }); 16 | const session = useSession({ 17 | required: true, 18 | onUnauthenticated() { 19 | replace("/"); 20 | }, 21 | }); 22 | 23 | const isMe = session.data?.user?.id === query.id; 24 | 25 | useEffect(() => { 26 | if (session.status === "authenticated" && !isMe) { 27 | replace("/"); 28 | } 29 | }, [isMe, replace, session.status]); 30 | 31 | if (profile.data === null) { 32 | return
Not found
; 33 | } 34 | 35 | return ( 36 |
37 | 38 |
39 | 59 |
60 | {isMe && ( 61 | 67 | )} 68 |
69 |
70 |
71 | ); 72 | }; 73 | 74 | export default ProfilePage; 75 | -------------------------------------------------------------------------------- /src/server/common/get-server-auth-session.ts: -------------------------------------------------------------------------------- 1 | // Wrapper for unstable_getServerSession https://next-auth.js.org/configuration/nextjs 2 | 3 | import type { GetServerSidePropsContext } from "next"; 4 | import { unstable_getServerSession } from "next-auth"; 5 | 6 | import { authOptions as nextAuthOptions } from "../../pages/api/auth/[...nextauth]"; 7 | 8 | // Next API route example - /pages/api/restricted.ts 9 | export const getServerAuthSession = async (ctx: { 10 | req: GetServerSidePropsContext["req"]; 11 | res: GetServerSidePropsContext["res"]; 12 | }) => { 13 | return await unstable_getServerSession(ctx.req, ctx.res, nextAuthOptions); 14 | }; 15 | -------------------------------------------------------------------------------- /src/server/db/client.ts: -------------------------------------------------------------------------------- 1 | // src/server/db/client.ts 2 | import { PrismaClient } from "@prisma/client"; 3 | 4 | import { env } from "../../env/server.mjs"; 5 | 6 | declare global { 7 | // eslint-disable-next-line no-var 8 | var prisma: PrismaClient | undefined; 9 | } 10 | 11 | export const prisma = 12 | global.prisma || 13 | new PrismaClient({ 14 | log: 15 | env.NODE_ENV === "development" ? ["query", "error", "warn"] : ["error"], 16 | }); 17 | 18 | if (env.NODE_ENV !== "production") { 19 | global.prisma = prisma; 20 | } 21 | -------------------------------------------------------------------------------- /src/server/trpc/context.ts: -------------------------------------------------------------------------------- 1 | // src/server/router/context.ts 2 | import type { inferAsyncReturnType } from "@trpc/server"; 3 | import type { CreateNextContextOptions } from "@trpc/server/adapters/next"; 4 | import type { Session } from "next-auth"; 5 | 6 | import { getServerAuthSession } from "../common/get-server-auth-session"; 7 | import { prisma } from "../db/client"; 8 | 9 | type CreateContextOptions = { 10 | session: Session | null; 11 | }; 12 | 13 | /** Use this helper for: 14 | * - testing, so we dont have to mock Next.js' req/res 15 | * - trpc's `createSSGHelpers` where we don't have req/res 16 | **/ 17 | export const createContextInner = async (opts: CreateContextOptions) => { 18 | return { 19 | session: opts.session, 20 | prisma, 21 | }; 22 | }; 23 | 24 | /** 25 | * This is the actual context you'll use in your router 26 | * @link https://trpc.io/docs/context 27 | **/ 28 | export const createContext = async (opts: CreateNextContextOptions) => { 29 | const { req, res } = opts; 30 | 31 | // Get the session from the server using the unstable_getServerSession wrapper function 32 | const session = await getServerAuthSession({ req, res }); 33 | 34 | return await createContextInner({ 35 | session, 36 | }); 37 | }; 38 | 39 | export type Context = inferAsyncReturnType; 40 | -------------------------------------------------------------------------------- /src/server/trpc/router/_app.ts: -------------------------------------------------------------------------------- 1 | // src/server/trpc/router/_app.ts 2 | import { router } from "../trpc"; 3 | import { imagesRouter } from "./images"; 4 | import { userRouter } from "./user"; 5 | 6 | export const appRouter = router({ 7 | images: imagesRouter, 8 | user: userRouter, 9 | }); 10 | 11 | // export type definition of API 12 | export type AppRouter = typeof appRouter; 13 | -------------------------------------------------------------------------------- /src/server/trpc/router/images.ts: -------------------------------------------------------------------------------- 1 | import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3"; 2 | import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; 3 | import { z } from "zod"; 4 | 5 | import { env } from "../../../env/server.mjs"; 6 | import { protectedProcedure, publicProcedure, router } from "../trpc"; 7 | 8 | const client = new S3Client({ 9 | region: env.UPLOAD_AWS_REGION, 10 | credentials: { 11 | accessKeyId: env.UPLOAD_AWS_ACCESS_KEY_ID, 12 | secretAccessKey: env.UPLOAD_AWS_SECRET_ACCESS_KEY, 13 | }, 14 | }); 15 | 16 | export const imagesRouter = router({ 17 | upload: publicProcedure 18 | .input( 19 | z 20 | .object({ 21 | slug: z.string(), 22 | width: z.number(), 23 | height: z.number(), 24 | }) 25 | .array(), 26 | ) 27 | .mutation(async ({ ctx, input }) => { 28 | const userId = ctx.session?.user?.id; 29 | const [urls] = await Promise.all([ 30 | await Promise.all( 31 | input.map(async ({ slug }) => { 32 | const command = new PutObjectCommand({ 33 | Bucket: env.UPLOAD_AWS_S3_BUCKET, 34 | Key: slug, 35 | }); 36 | return { 37 | slug, 38 | url: await getSignedUrl(client, command, { expiresIn: 3600 }), 39 | }; 40 | }), 41 | ), 42 | await ctx.prisma.image.createMany({ 43 | data: input.map((images) => ({ 44 | ...images, 45 | userId, 46 | })), 47 | }), 48 | ]); 49 | return urls; 50 | }), 51 | get: publicProcedure.input(z.string()).query(async ({ ctx, input }) => { 52 | const image = await ctx.prisma.image.findUnique({ 53 | where: { slug: input }, 54 | include: { user: true }, 55 | }); 56 | if (!image) { 57 | throw new Error("Image not found"); 58 | } 59 | return image; 60 | }), 61 | delete: protectedProcedure 62 | .input(z.number()) 63 | .mutation(async ({ ctx, input }) => { 64 | const userId = ctx.session.user.id; 65 | const image = await ctx.prisma.image.findUnique({ 66 | where: { id: input }, 67 | }); 68 | if (!image) throw new Error("Image not found"); 69 | if (image.userId !== userId) throw new Error("Not your image"); 70 | await ctx.prisma.image.delete({ 71 | where: { 72 | id: input, 73 | }, 74 | }); 75 | return image; 76 | }), 77 | 78 | deleteBySlug: protectedProcedure 79 | .input(z.string()) 80 | .mutation(async ({ ctx, input }) => { 81 | const userId = ctx.session.user.id; 82 | const image = await ctx.prisma.image.findUnique({ 83 | where: { slug: input }, 84 | }); 85 | if (!image) throw new Error("Image not found"); 86 | if (image.userId !== userId) throw new Error("Not your image"); 87 | await ctx.prisma.image.delete({ 88 | where: { 89 | slug: input, 90 | }, 91 | }); 92 | return image; 93 | }), 94 | }); 95 | -------------------------------------------------------------------------------- /src/server/trpc/router/user.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | 3 | import { publicProcedure, router } from "../trpc"; 4 | 5 | export const userRouter = router({ 6 | profile: publicProcedure.input(z.string()).query(async ({ ctx, input }) => { 7 | const user = await ctx.prisma.user.findUnique({ 8 | where: { id: input }, 9 | include: { images: { orderBy: { createdAt: "desc" } } }, 10 | }); 11 | return user; 12 | }), 13 | }); 14 | -------------------------------------------------------------------------------- /src/server/trpc/trpc.ts: -------------------------------------------------------------------------------- 1 | import { initTRPC, TRPCError } from "@trpc/server"; 2 | import superjson from "superjson"; 3 | 4 | import type { Context } from "./context"; 5 | 6 | const t = initTRPC.context().create({ 7 | transformer: superjson, 8 | errorFormatter({ shape }) { 9 | return shape; 10 | }, 11 | }); 12 | 13 | export const router = t.router; 14 | 15 | /** 16 | * Unprotected procedure 17 | **/ 18 | export const publicProcedure = t.procedure; 19 | 20 | /** 21 | * Reusable middleware to ensure 22 | * users are logged in 23 | */ 24 | const isAuthed = t.middleware(({ ctx, next }) => { 25 | if (!ctx.session || !ctx.session.user) { 26 | throw new TRPCError({ code: "UNAUTHORIZED" }); 27 | } 28 | return next({ 29 | ctx: { 30 | // infers the `session` as non-nullable 31 | session: { ...ctx.session, user: ctx.session.user }, 32 | }, 33 | }); 34 | }); 35 | 36 | /** 37 | * Protected procedure 38 | **/ 39 | export const protectedProcedure = t.procedure.use(isAuthed); 40 | -------------------------------------------------------------------------------- /src/styles/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | -------------------------------------------------------------------------------- /src/types/next-auth.d.ts: -------------------------------------------------------------------------------- 1 | import { DefaultSession } from "next-auth"; 2 | 3 | declare module "next-auth" { 4 | /** 5 | * Returned by `useSession`, `getSession` and received as a prop on the `SessionProvider` React Context 6 | */ 7 | interface Session { 8 | user?: { 9 | id: string; 10 | } & DefaultSession["user"]; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/utils/cx.ts: -------------------------------------------------------------------------------- 1 | import clsx, { ClassValue } from "clsx"; 2 | import { twMerge } from "tailwind-merge"; 3 | 4 | export const cx = (...values: ClassValue[]) => twMerge(clsx(...values)); 5 | -------------------------------------------------------------------------------- /src/utils/trpc.ts: -------------------------------------------------------------------------------- 1 | // src/utils/trpc.ts 2 | import { httpBatchLink, loggerLink } from "@trpc/client"; 3 | import { createTRPCNext } from "@trpc/next"; 4 | import superjson from "superjson"; 5 | 6 | import type { AppRouter } from "../server/trpc/router/_app"; 7 | 8 | const getBaseUrl = () => { 9 | if (typeof window !== "undefined") return ""; // browser should use relative url 10 | if (process.env.VERCEL_URL) return `https://${process.env.VERCEL_URL}`; // SSR should use vercel url 11 | return `http://localhost:${process.env.PORT ?? 3000}`; // dev SSR should use localhost 12 | }; 13 | 14 | export const trpc = createTRPCNext({ 15 | config() { 16 | return { 17 | transformer: superjson, 18 | links: [ 19 | loggerLink({ 20 | enabled: (opts) => 21 | process.env.NODE_ENV === "development" || 22 | (opts.direction === "down" && opts.result instanceof Error), 23 | }), 24 | httpBatchLink({ 25 | url: `${getBaseUrl()}/api/trpc`, 26 | }), 27 | ], 28 | }; 29 | }, 30 | ssr: false, 31 | }); 32 | -------------------------------------------------------------------------------- /tailwind.config.cjs: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | content: ["./src/**/*.{js,ts,jsx,tsx}"], 4 | theme: { 5 | extend: { 6 | animation: { 7 | fade: "fadeIn 5s ease-in-out", 8 | }, 9 | keyframes: (theme) => ({ 10 | fadeIn: { 11 | "0%": { backgroundColor: theme("opacity.0") }, 12 | "100%": { backgroundColor: theme("opacity.100") }, 13 | }, 14 | }), 15 | }, 16 | }, 17 | plugins: [], 18 | }; 19 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2015", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "noEmit": true, 10 | "esModuleInterop": true, 11 | "module": "esnext", 12 | "moduleResolution": "node", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "jsx": "preserve", 16 | "incremental": true, 17 | "noUncheckedIndexedAccess": false, 18 | "plugins": [ 19 | { 20 | "name": "next" 21 | } 22 | ] 23 | }, 24 | "include": [ 25 | "next-env.d.ts", 26 | "**/*.ts", 27 | "**/*.tsx", 28 | "**/*.cjs", 29 | "**/*.mjs", 30 | ".next/types/**/*.ts" 31 | ], 32 | "exclude": ["node_modules"] 33 | } 34 | --------------------------------------------------------------------------------