boilerplate
This commit is contained in:
4
.env.example
Normal file
4
.env.example
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/web_boilerplate"
|
||||||
|
BETTER_AUTH_SECRET="replace-with-a-32-character-secret"
|
||||||
|
BETTER_AUTH_URL="http://localhost:3456"
|
||||||
|
NODE_ENV="development"
|
||||||
19
.gitignore
vendored
Normal file
19
.gitignore
vendored
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
node_modules/
|
||||||
|
.next/
|
||||||
|
out/
|
||||||
|
dist/
|
||||||
|
.cache/
|
||||||
|
.parcel-cache/
|
||||||
|
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.development.local
|
||||||
|
.env.production.local
|
||||||
|
.env.test.local
|
||||||
|
|
||||||
|
generated/prisma
|
||||||
|
*.tsbuildinfo
|
||||||
|
*.tgz
|
||||||
|
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
73
README.md
73
README.md
@@ -0,0 +1,73 @@
|
|||||||
|
# Web Boilerplate
|
||||||
|
|
||||||
|
Generic Next.js Pages Router boilerplate built from the shared stack used in `resonate-gallery`.
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
- Next.js 16 Pages Router
|
||||||
|
- React 19
|
||||||
|
- Tailwind CSS 4
|
||||||
|
- tRPC 11
|
||||||
|
- Prisma 7 with PostgreSQL
|
||||||
|
- Better Auth email/password authentication
|
||||||
|
- Biome
|
||||||
|
|
||||||
|
## Models
|
||||||
|
|
||||||
|
- `User`
|
||||||
|
- `Session`
|
||||||
|
- `Account`
|
||||||
|
- `Verification`
|
||||||
|
- `Post`
|
||||||
|
- `Comment`
|
||||||
|
|
||||||
|
## Routes
|
||||||
|
|
||||||
|
- Public: `/`, `/posts`, `/posts/[postId]`, `/sign-in`, `/sign-up`
|
||||||
|
- Protected: `/dashboard`, `/account`
|
||||||
|
|
||||||
|
## Bootstrap
|
||||||
|
|
||||||
|
1. Install dependencies.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm install
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Copy the example environment file and set a real PostgreSQL connection string plus Better Auth secret.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Apply the committed migration or generate a local dev database from scratch.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm db:migrate
|
||||||
|
```
|
||||||
|
|
||||||
|
Or for local development:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm db:generate
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Regenerate the Prisma client if needed.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm exec prisma generate
|
||||||
|
```
|
||||||
|
|
||||||
|
5. Run the app.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm typecheck
|
||||||
|
pnpm check
|
||||||
|
pnpm build
|
||||||
|
```
|
||||||
|
|||||||
79
biome.jsonc
Normal file
79
biome.jsonc
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
{
|
||||||
|
"$schema": "./node_modules/@biomejs/biome/configuration_schema.json",
|
||||||
|
"root": true,
|
||||||
|
"vcs": {
|
||||||
|
"enabled": true,
|
||||||
|
"useIgnoreFile": true,
|
||||||
|
"clientKind": "git"
|
||||||
|
},
|
||||||
|
"files": {
|
||||||
|
"includes": ["**", "!!node_modules", "!!generated"]
|
||||||
|
},
|
||||||
|
"assist": {
|
||||||
|
"enabled": true,
|
||||||
|
"actions": {
|
||||||
|
"recommended": true,
|
||||||
|
"source": {
|
||||||
|
"recommended": true,
|
||||||
|
"organizeImports": "on",
|
||||||
|
"useSortedAttributes": "on"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"formatter": {
|
||||||
|
"enabled": true,
|
||||||
|
"indentStyle": "space",
|
||||||
|
"indentWidth": 2,
|
||||||
|
"lineWidth": 140
|
||||||
|
},
|
||||||
|
"linter": {
|
||||||
|
"enabled": true,
|
||||||
|
"rules": {
|
||||||
|
"recommended": true,
|
||||||
|
"nursery": {
|
||||||
|
"useSortedClasses": {
|
||||||
|
"level": "warn",
|
||||||
|
"fix": "safe",
|
||||||
|
"options": {
|
||||||
|
"attributes": ["className", "class"],
|
||||||
|
"functions": ["clsx", "twMerge", "cn"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"html": {
|
||||||
|
"formatter": {
|
||||||
|
"enabled": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"javascript": {
|
||||||
|
"assist": {
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"formatter": {
|
||||||
|
"enabled": true,
|
||||||
|
"arrowParentheses": "always",
|
||||||
|
"semicolons": "always",
|
||||||
|
"trailingCommas": "all"
|
||||||
|
},
|
||||||
|
"linter": {
|
||||||
|
"enabled": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"css": {
|
||||||
|
"assist": {
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"formatter": {
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"linter": {
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"parser": {
|
||||||
|
"cssModules": true,
|
||||||
|
"tailwindDirectives": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
6
next-env.d.ts
vendored
Normal file
6
next-env.d.ts
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
/// <reference types="next" />
|
||||||
|
/// <reference types="next/image-types/global" />
|
||||||
|
import "./.next/types/routes.d.ts";
|
||||||
|
|
||||||
|
// NOTE: This file should not be edited
|
||||||
|
// see https://nextjs.org/docs/pages/api-reference/config/typescript for more information.
|
||||||
19
next.config.ts
Normal file
19
next.config.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import "./src/env.js";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
|
const dirname = fileURLToPath(new URL(".", import.meta.url));
|
||||||
|
|
||||||
|
const config = {
|
||||||
|
reactStrictMode: true,
|
||||||
|
i18n: {
|
||||||
|
locales: ["en"],
|
||||||
|
defaultLocale: "en",
|
||||||
|
},
|
||||||
|
turbopack: {
|
||||||
|
root: dirname,
|
||||||
|
},
|
||||||
|
reactCompiler: true,
|
||||||
|
} as NextConfig;
|
||||||
|
|
||||||
|
export default config;
|
||||||
50
package.json
Normal file
50
package.json
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
{
|
||||||
|
"name": "web-boilerplate",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"build": "SKIP_ENV_VALIDATION=1 next build",
|
||||||
|
"check": "biome check .",
|
||||||
|
"check:unsafe": "biome check --write --unsafe .",
|
||||||
|
"check:write": "biome check --write .",
|
||||||
|
"db:generate": "prisma migrate dev",
|
||||||
|
"db:migrate": "prisma migrate deploy",
|
||||||
|
"db:push": "prisma db push",
|
||||||
|
"db:studio": "prisma studio",
|
||||||
|
"dev": "next dev --turbo -p 3456",
|
||||||
|
"postinstall": "prisma generate",
|
||||||
|
"preview": "next build && next start",
|
||||||
|
"start": "next start",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@prisma/adapter-pg": "^7.4.2",
|
||||||
|
"@prisma/client": "^7.4.2",
|
||||||
|
"@t3-oss/env-nextjs": "^0.13.10",
|
||||||
|
"@tanstack/react-query": "^5.90.21",
|
||||||
|
"@trpc/client": "^11.10.0",
|
||||||
|
"@trpc/next": "^11.10.0",
|
||||||
|
"@trpc/react-query": "^11.10.0",
|
||||||
|
"@trpc/server": "^11.10.0",
|
||||||
|
"babel-plugin-react-compiler": "^1.0.0",
|
||||||
|
"better-auth": "^1.5.2",
|
||||||
|
"next": "^16.1.6",
|
||||||
|
"react": "^19.2.4",
|
||||||
|
"react-dom": "^19.2.4",
|
||||||
|
"superjson": "^2.2.6",
|
||||||
|
"zod": "^4.3.6"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@biomejs/biome": "^2.4.4",
|
||||||
|
"@tailwindcss/postcss": "^4.2.1",
|
||||||
|
"@types/node": "^25.3.2",
|
||||||
|
"@types/react": "^19.2.14",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"postcss": "^8.5.6",
|
||||||
|
"prisma": "^7.4.2",
|
||||||
|
"tailwindcss": "^4.2.1",
|
||||||
|
"typescript": "^5.9.3"
|
||||||
|
},
|
||||||
|
"packageManager": "pnpm@10.30.3"
|
||||||
|
}
|
||||||
2573
pnpm-lock.yaml
generated
Normal file
2573
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
5
pnpm-workspace.yaml
Normal file
5
pnpm-workspace.yaml
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
onlyBuiltDependencies:
|
||||||
|
- "@prisma/engines"
|
||||||
|
- prisma
|
||||||
|
- sharp
|
||||||
|
nodeLinker: hoisted
|
||||||
5
postcss.config.js
Normal file
5
postcss.config.js
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
"@tailwindcss/postcss": {},
|
||||||
|
},
|
||||||
|
};
|
||||||
9
prisma.config.ts
Normal file
9
prisma.config.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import "dotenv/config";
|
||||||
|
import { defineConfig } from "prisma/config";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
schema: "prisma/schema.prisma",
|
||||||
|
datasource: {
|
||||||
|
url: process.env.DATABASE_URL,
|
||||||
|
},
|
||||||
|
});
|
||||||
117
prisma/migrations/20260304120000_init/migration.sql
Normal file
117
prisma/migrations/20260304120000_init/migration.sql
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
-- CreateSchema
|
||||||
|
CREATE SCHEMA IF NOT EXISTS "public";
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "user" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"email" TEXT NOT NULL,
|
||||||
|
"emailVerified" BOOLEAN NOT NULL,
|
||||||
|
"image" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "user_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "session" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"token" TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"ipAddress" TEXT,
|
||||||
|
"userAgent" TEXT,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "session_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "account" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"accountId" TEXT NOT NULL,
|
||||||
|
"providerId" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"accessToken" TEXT,
|
||||||
|
"refreshToken" TEXT,
|
||||||
|
"idToken" TEXT,
|
||||||
|
"accessTokenExpiresAt" TIMESTAMP(3),
|
||||||
|
"refreshTokenExpiresAt" TIMESTAMP(3),
|
||||||
|
"scope" TEXT,
|
||||||
|
"password" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "account_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "verification" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"identifier" TEXT NOT NULL,
|
||||||
|
"value" TEXT NOT NULL,
|
||||||
|
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3),
|
||||||
|
"updatedAt" TIMESTAMP(3),
|
||||||
|
|
||||||
|
CONSTRAINT "verification_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Post" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"title" TEXT NOT NULL,
|
||||||
|
"body" TEXT NOT NULL,
|
||||||
|
"authorId" TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "Post_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Comment" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"body" TEXT NOT NULL,
|
||||||
|
"postId" TEXT NOT NULL,
|
||||||
|
"authorId" TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "Comment_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "user_email_key" ON "user"("email");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "session_token_key" ON "session"("token");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Post_authorId_idx" ON "Post"("authorId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Post_createdAt_idx" ON "Post"("createdAt");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Comment_postId_idx" ON "Comment"("postId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Comment_authorId_idx" ON "Comment"("authorId");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "session" ADD CONSTRAINT "session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "account" ADD CONSTRAINT "account_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Post" ADD CONSTRAINT "Post_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Comment" ADD CONSTRAINT "Comment_postId_fkey" FOREIGN KEY ("postId") REFERENCES "Post"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Comment" ADD CONSTRAINT "Comment_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
1
prisma/migrations/migration_lock.toml
Normal file
1
prisma/migrations/migration_lock.toml
Normal file
@@ -0,0 +1 @@
|
|||||||
|
provider = "postgresql"
|
||||||
102
prisma/schema.prisma
Normal file
102
prisma/schema.prisma
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
generator client {
|
||||||
|
provider = "prisma-client"
|
||||||
|
output = "../generated/prisma"
|
||||||
|
|
||||||
|
previewFeatures = ["fullTextSearchPostgres", "relationJoins", "nativeDistinct", "views"]
|
||||||
|
moduleFormat = "esm"
|
||||||
|
generatedFileExtension = "ts"
|
||||||
|
importFileExtension = "ts"
|
||||||
|
engineType = "client"
|
||||||
|
}
|
||||||
|
|
||||||
|
datasource db {
|
||||||
|
provider = "postgresql"
|
||||||
|
}
|
||||||
|
|
||||||
|
model User {
|
||||||
|
id String @id
|
||||||
|
name String
|
||||||
|
email String @unique
|
||||||
|
emailVerified Boolean
|
||||||
|
image String?
|
||||||
|
createdAt DateTime
|
||||||
|
updatedAt DateTime
|
||||||
|
sessions Session[]
|
||||||
|
accounts Account[]
|
||||||
|
posts Post[]
|
||||||
|
comments Comment[]
|
||||||
|
|
||||||
|
@@map("user")
|
||||||
|
}
|
||||||
|
|
||||||
|
model Session {
|
||||||
|
id String @id
|
||||||
|
expiresAt DateTime
|
||||||
|
token String @unique
|
||||||
|
createdAt DateTime
|
||||||
|
updatedAt DateTime
|
||||||
|
ipAddress String?
|
||||||
|
userAgent String?
|
||||||
|
userId String
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@map("session")
|
||||||
|
}
|
||||||
|
|
||||||
|
model Account {
|
||||||
|
id String @id
|
||||||
|
accountId String
|
||||||
|
providerId String
|
||||||
|
userId String
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
accessToken String?
|
||||||
|
refreshToken String?
|
||||||
|
idToken String?
|
||||||
|
accessTokenExpiresAt DateTime?
|
||||||
|
refreshTokenExpiresAt DateTime?
|
||||||
|
scope String?
|
||||||
|
password String?
|
||||||
|
createdAt DateTime
|
||||||
|
updatedAt DateTime
|
||||||
|
|
||||||
|
@@map("account")
|
||||||
|
}
|
||||||
|
|
||||||
|
model Verification {
|
||||||
|
id String @id
|
||||||
|
identifier String
|
||||||
|
value String
|
||||||
|
expiresAt DateTime
|
||||||
|
createdAt DateTime?
|
||||||
|
updatedAt DateTime?
|
||||||
|
|
||||||
|
@@map("verification")
|
||||||
|
}
|
||||||
|
|
||||||
|
model Post {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
title String
|
||||||
|
body String
|
||||||
|
authorId String
|
||||||
|
author User @relation(fields: [authorId], references: [id], onDelete: Cascade)
|
||||||
|
comments Comment[]
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@index([authorId])
|
||||||
|
@@index([createdAt])
|
||||||
|
}
|
||||||
|
|
||||||
|
model Comment {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
body String
|
||||||
|
postId String
|
||||||
|
post Post @relation(fields: [postId], references: [id], onDelete: Cascade)
|
||||||
|
authorId String
|
||||||
|
author User @relation(fields: [authorId], references: [id], onDelete: Cascade)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@index([postId])
|
||||||
|
@@index([authorId])
|
||||||
|
}
|
||||||
159
src/components/AuthForm.tsx
Normal file
159
src/components/AuthForm.tsx
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/router";
|
||||||
|
import { startTransition, useState } from "react";
|
||||||
|
|
||||||
|
import { signIn, signUp } from "~/utils/auth-client";
|
||||||
|
|
||||||
|
type AuthFormProps = {
|
||||||
|
mode: "sign-in" | "sign-up";
|
||||||
|
nextPath: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const inputClassName =
|
||||||
|
"w-full rounded-[18px] border border-white/12 bg-[#0b1320] px-4 py-3 text-sm text-white outline-none transition placeholder:text-[#6c7790] focus:border-[#9db6ff] focus:bg-[#101928]";
|
||||||
|
|
||||||
|
export const AuthForm = ({ mode, nextPath }: AuthFormProps) => {
|
||||||
|
const router = useRouter();
|
||||||
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||||
|
const [isPending, setIsPending] = useState(false);
|
||||||
|
const [formState, setFormState] = useState({
|
||||||
|
email: "",
|
||||||
|
name: "",
|
||||||
|
password: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
const isSignUp = mode === "sign-up";
|
||||||
|
const alternateHref = `${isSignUp ? "/sign-in" : "/sign-up"}?next=${encodeURIComponent(nextPath)}`;
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
setIsPending(true);
|
||||||
|
setErrorMessage(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = isSignUp
|
||||||
|
? await signUp.email({
|
||||||
|
name: formState.name,
|
||||||
|
email: formState.email,
|
||||||
|
password: formState.password,
|
||||||
|
callbackURL: nextPath,
|
||||||
|
})
|
||||||
|
: await signIn.email({
|
||||||
|
email: formState.email,
|
||||||
|
password: formState.password,
|
||||||
|
callbackURL: nextPath,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.error) {
|
||||||
|
setErrorMessage(result.error.message ?? "Unable to continue with authentication.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
startTransition(() => {
|
||||||
|
void router.replace(nextPath);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
setErrorMessage(error instanceof Error ? error.message : "Unable to continue with authentication.");
|
||||||
|
} finally {
|
||||||
|
setIsPending(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-xl rounded-[30px] border border-white/12 bg-[#0a1220]/90 p-6 shadow-[0_24px_90px_rgba(3,7,18,0.35)] md:p-8">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<p className="font-mono text-[#99b6ff] text-[11px] uppercase tracking-[0.28em]">
|
||||||
|
{isSignUp ? "Create an account" : "Welcome back"}
|
||||||
|
</p>
|
||||||
|
<h2 className="text-2xl tracking-[-0.04em]">{isSignUp ? "Set up email and password auth" : "Sign in to the protected area"}</h2>
|
||||||
|
<p className="text-[#c5d0ea] text-sm leading-6">
|
||||||
|
{isSignUp
|
||||||
|
? "This boilerplate only enables Better Auth email and password flows."
|
||||||
|
: "Use the same credentials you created with Better Auth email and password."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form
|
||||||
|
className="mt-8 space-y-4"
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
void handleSubmit();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isSignUp ? (
|
||||||
|
<label className="block space-y-2">
|
||||||
|
<span className="font-mono text-[#8f9ab6] text-[11px] uppercase tracking-[0.24em]">Name</span>
|
||||||
|
<input
|
||||||
|
className={inputClassName}
|
||||||
|
onChange={(event) =>
|
||||||
|
setFormState((current) => ({
|
||||||
|
...current,
|
||||||
|
name: event.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
placeholder="Jane Example"
|
||||||
|
required
|
||||||
|
type="text"
|
||||||
|
value={formState.name}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<label className="block space-y-2">
|
||||||
|
<span className="font-mono text-[#8f9ab6] text-[11px] uppercase tracking-[0.24em]">Email</span>
|
||||||
|
<input
|
||||||
|
autoComplete="email"
|
||||||
|
className={inputClassName}
|
||||||
|
onChange={(event) =>
|
||||||
|
setFormState((current) => ({
|
||||||
|
...current,
|
||||||
|
email: event.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
placeholder="jane@example.com"
|
||||||
|
required
|
||||||
|
type="email"
|
||||||
|
value={formState.email}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="block space-y-2">
|
||||||
|
<span className="font-mono text-[#8f9ab6] text-[11px] uppercase tracking-[0.24em]">Password</span>
|
||||||
|
<input
|
||||||
|
autoComplete={isSignUp ? "new-password" : "current-password"}
|
||||||
|
className={inputClassName}
|
||||||
|
minLength={8}
|
||||||
|
onChange={(event) =>
|
||||||
|
setFormState((current) => ({
|
||||||
|
...current,
|
||||||
|
password: event.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
placeholder="At least 8 characters"
|
||||||
|
required
|
||||||
|
type="password"
|
||||||
|
value={formState.password}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{errorMessage ? (
|
||||||
|
<div className="rounded-[18px] border border-[#73464a] bg-[#241114] px-4 py-3 text-[#ffc9d0] text-sm">{errorMessage}</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="w-full rounded-[18px] bg-[#9db6ff] px-4 py-3 font-mono text-[#09111d] text-[11px] uppercase tracking-[0.24em] transition hover:bg-[#b8cbff] disabled:cursor-not-allowed disabled:bg-[#556888] disabled:text-[#d4def8]"
|
||||||
|
disabled={isPending}
|
||||||
|
type="submit"
|
||||||
|
>
|
||||||
|
{isPending ? "Working..." : isSignUp ? "Create account" : "Sign in"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p className="mt-5 text-[#b6c0da] text-sm">
|
||||||
|
{isSignUp ? "Already have an account?" : "Need an account?"}{" "}
|
||||||
|
<Link className="text-[#9db6ff] underline underline-offset-4" href={alternateHref}>
|
||||||
|
{isSignUp ? "Sign in here" : "Create one here"}
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
69
src/components/CommentForm.tsx
Normal file
69
src/components/CommentForm.tsx
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
import { api } from "~/utils/api";
|
||||||
|
|
||||||
|
type CommentFormProps = {
|
||||||
|
postId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const inputClassName =
|
||||||
|
"w-full rounded-[18px] border border-white/12 bg-[#0b1320] px-4 py-3 text-sm text-white outline-none transition placeholder:text-[#6c7790] focus:border-[#9db6ff] focus:bg-[#101928]";
|
||||||
|
|
||||||
|
export const CommentForm = ({ postId }: CommentFormProps) => {
|
||||||
|
const utils = api.useUtils();
|
||||||
|
const [body, setBody] = useState("");
|
||||||
|
const [message, setMessage] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const createComment = api.comment.create.useMutation({
|
||||||
|
onSuccess: async () => {
|
||||||
|
setBody("");
|
||||||
|
setMessage("Comment created.");
|
||||||
|
await utils.post.byId.invalidate({ postId });
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
setMessage(error.message);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="rounded-[26px] border border-white/12 bg-[#0a1220]/85 p-5">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<p className="font-mono text-[#99b6ff] text-[11px] uppercase tracking-[0.24em]">Protected write route</p>
|
||||||
|
<h2 className="text-xl tracking-[-0.04em]">Add a comment</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form
|
||||||
|
className="mt-5 space-y-4"
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setMessage(null);
|
||||||
|
createComment.mutate({
|
||||||
|
body,
|
||||||
|
postId,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<textarea
|
||||||
|
className={`${inputClassName} min-h-32 resize-y`}
|
||||||
|
maxLength={4000}
|
||||||
|
onChange={(event) => setBody(event.target.value)}
|
||||||
|
placeholder="Leave a short note on the post."
|
||||||
|
required
|
||||||
|
value={body}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{message ? (
|
||||||
|
<div className="rounded-[18px] border border-white/10 bg-white/[0.04] px-4 py-3 text-[#dbe4ff] text-sm">{message}</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="rounded-[18px] bg-[#9db6ff] px-4 py-3 font-mono text-[#09111d] text-[11px] uppercase tracking-[0.24em] transition hover:bg-[#b8cbff] disabled:cursor-not-allowed disabled:bg-[#556888] disabled:text-[#d4def8]"
|
||||||
|
disabled={createComment.isPending}
|
||||||
|
type="submit"
|
||||||
|
>
|
||||||
|
{createComment.isPending ? "Posting..." : "Post comment"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
105
src/components/PageShell.tsx
Normal file
105
src/components/PageShell.tsx
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/router";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
import { useSession } from "~/utils/auth-client";
|
||||||
|
|
||||||
|
type PageShellProps = {
|
||||||
|
actions?: ReactNode;
|
||||||
|
children: ReactNode;
|
||||||
|
eyebrow?: string;
|
||||||
|
intro: string;
|
||||||
|
title: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const linkClassName =
|
||||||
|
"rounded-full border px-4 py-2 font-mono text-[11px] uppercase tracking-[0.22em] transition hover:border-white/40 hover:bg-white/8";
|
||||||
|
|
||||||
|
const isActivePath = (pathname: string, href: string) => {
|
||||||
|
if (href === "/") {
|
||||||
|
return pathname === href;
|
||||||
|
}
|
||||||
|
|
||||||
|
return pathname === href || pathname.startsWith(`${href}/`);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PageShell = ({ actions, children, eyebrow, intro, title }: PageShellProps) => {
|
||||||
|
const router = useRouter();
|
||||||
|
const { data: session } = useSession();
|
||||||
|
|
||||||
|
const primaryLinks = [
|
||||||
|
{ href: "/", label: "Overview" },
|
||||||
|
{ href: "/posts", label: "Posts" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const secondaryLinks = session
|
||||||
|
? [
|
||||||
|
{ href: "/dashboard", label: "Dashboard" },
|
||||||
|
{ href: "/account", label: "Account" },
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
{ href: "/sign-in", label: "Sign in" },
|
||||||
|
{ href: "/sign-up", label: "Sign up" },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-[radial-gradient(circle_at_top,rgba(93,127,255,0.24),transparent_38%),linear-gradient(180deg,#08111b_0%,#0d1522_45%,#060b12_100%)] text-[#f3f6ff]">
|
||||||
|
<div className="mx-auto flex min-h-screen w-full max-w-6xl flex-col px-5 py-6 sm:px-8 lg:px-10">
|
||||||
|
<header className="rounded-[28px] border border-white/12 bg-white/[0.04] px-5 py-5 shadow-[0_24px_80px_rgba(2,6,23,0.45)] backdrop-blur md:px-7">
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||||
|
<div className="max-w-2xl">
|
||||||
|
<p className="font-mono text-[#99b6ff] text-[11px] uppercase tracking-[0.32em]">{eyebrow ?? "Generic Boilerplate"}</p>
|
||||||
|
<h1 className="mt-3 text-3xl tracking-[-0.04em] sm:text-4xl lg:text-5xl">{title}</h1>
|
||||||
|
<p className="mt-4 max-w-2xl text-[#c5d0ea] text-[15px] leading-7 sm:text-base">{intro}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-3 lg:items-end">
|
||||||
|
<div className="rounded-full border border-white/10 bg-[#101b2a] px-4 py-2 text-[#d6ddf2] text-sm">
|
||||||
|
{session ? `Signed in as ${session.user.email}` : "Browsing public routes"}
|
||||||
|
</div>
|
||||||
|
{actions ? <div className="flex flex-wrap gap-3">{actions}</div> : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-3 border-white/10 border-t pt-4 lg:flex-row lg:items-center lg:justify-between">
|
||||||
|
<nav className="flex flex-wrap gap-3">
|
||||||
|
{primaryLinks.map((link) => (
|
||||||
|
<Link
|
||||||
|
className={`${linkClassName} ${
|
||||||
|
isActivePath(router.pathname, link.href)
|
||||||
|
? "border-[#9db6ff] bg-[#122138] text-white"
|
||||||
|
: "border-white/12 text-[#d7e0f4]"
|
||||||
|
}`}
|
||||||
|
href={link.href}
|
||||||
|
key={link.href}
|
||||||
|
>
|
||||||
|
{link.label}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<nav className="flex flex-wrap gap-3">
|
||||||
|
{secondaryLinks.map((link) => (
|
||||||
|
<Link
|
||||||
|
className={`${linkClassName} ${
|
||||||
|
isActivePath(router.pathname, link.href)
|
||||||
|
? "border-[#9db6ff] bg-[#122138] text-white"
|
||||||
|
: "border-white/12 text-[#d7e0f4]"
|
||||||
|
}`}
|
||||||
|
href={link.href}
|
||||||
|
key={link.href}
|
||||||
|
>
|
||||||
|
{link.label}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="flex-1 py-8">{children}</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
33
src/components/PostCard.tsx
Normal file
33
src/components/PostCard.tsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
import type { RouterOutputs } from "~/utils/api";
|
||||||
|
import { formatDateTime, truncateText } from "~/utils/formatting";
|
||||||
|
|
||||||
|
type PostCardProps = {
|
||||||
|
post: RouterOutputs["post"]["list"][number];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PostCard = ({ post }: PostCardProps) => {
|
||||||
|
return (
|
||||||
|
<article className="flex h-full flex-col rounded-[26px] border border-white/12 bg-white/[0.05] p-5 shadow-[0_18px_70px_rgba(2,6,23,0.25)]">
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<p className="font-mono text-[#8fa6dd] text-[11px] uppercase tracking-[0.24em]">{post.author.name}</p>
|
||||||
|
<h3 className="mt-3 text-xl tracking-[-0.04em]">{post.title}</h3>
|
||||||
|
</div>
|
||||||
|
<span className="rounded-full border border-white/10 px-3 py-1 font-mono text-[#b5c0da] text-[10px] uppercase tracking-[0.22em]">
|
||||||
|
{post._count.comments} comments
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="mt-4 flex-1 text-[#cbd4eb] text-sm leading-7">{truncateText(post.body, 220)}</p>
|
||||||
|
|
||||||
|
<div className="mt-6 flex flex-wrap items-center justify-between gap-3 border-white/10 border-t pt-4 text-[#99a5c4] text-sm">
|
||||||
|
<span>{formatDateTime(post.createdAt)}</span>
|
||||||
|
<Link className="font-mono text-[#9db6ff] text-[11px] uppercase tracking-[0.24em]" href={`/posts/${post.id}`}>
|
||||||
|
Read post
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
};
|
||||||
97
src/components/PostForm.tsx
Normal file
97
src/components/PostForm.tsx
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
import { api } from "~/utils/api";
|
||||||
|
|
||||||
|
const inputClassName =
|
||||||
|
"w-full rounded-[18px] border border-white/12 bg-[#0b1320] px-4 py-3 text-sm text-white outline-none transition placeholder:text-[#6c7790] focus:border-[#9db6ff] focus:bg-[#101928]";
|
||||||
|
|
||||||
|
export const PostForm = () => {
|
||||||
|
const utils = api.useUtils();
|
||||||
|
const [message, setMessage] = useState<string | null>(null);
|
||||||
|
const [formState, setFormState] = useState({
|
||||||
|
body: "",
|
||||||
|
title: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
const createPost = api.post.create.useMutation({
|
||||||
|
onSuccess: async () => {
|
||||||
|
setFormState({
|
||||||
|
body: "",
|
||||||
|
title: "",
|
||||||
|
});
|
||||||
|
setMessage("Post created.");
|
||||||
|
await Promise.all([utils.post.list.invalidate(), utils.post.mine.invalidate()]);
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
setMessage(error.message);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="rounded-[28px] border border-white/12 bg-[#0a1220]/85 p-6 shadow-[0_18px_70px_rgba(2,6,23,0.24)]">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<p className="font-mono text-[#99b6ff] text-[11px] uppercase tracking-[0.28em]">Protected write route</p>
|
||||||
|
<h2 className="text-2xl tracking-[-0.04em]">Create a post</h2>
|
||||||
|
<p className="text-[#c5d0ea] text-sm leading-7">
|
||||||
|
This form calls the protected `post.create` tRPC mutation with the signed-in user.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form
|
||||||
|
className="mt-6 space-y-4"
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setMessage(null);
|
||||||
|
createPost.mutate(formState);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<label className="block space-y-2">
|
||||||
|
<span className="font-mono text-[#8f9ab6] text-[11px] uppercase tracking-[0.24em]">Title</span>
|
||||||
|
<input
|
||||||
|
className={inputClassName}
|
||||||
|
maxLength={160}
|
||||||
|
onChange={(event) =>
|
||||||
|
setFormState((current) => ({
|
||||||
|
...current,
|
||||||
|
title: event.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
placeholder="Building the first route in a fresh app"
|
||||||
|
required
|
||||||
|
type="text"
|
||||||
|
value={formState.title}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="block space-y-2">
|
||||||
|
<span className="font-mono text-[#8f9ab6] text-[11px] uppercase tracking-[0.24em]">Body</span>
|
||||||
|
<textarea
|
||||||
|
className={`${inputClassName} min-h-40 resize-y`}
|
||||||
|
maxLength={10000}
|
||||||
|
onChange={(event) =>
|
||||||
|
setFormState((current) => ({
|
||||||
|
...current,
|
||||||
|
body: event.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
placeholder="Describe a small workflow, a data model, or a route that helped you validate the scaffold."
|
||||||
|
required
|
||||||
|
value={formState.body}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{message ? (
|
||||||
|
<div className="rounded-[18px] border border-white/10 bg-white/[0.04] px-4 py-3 text-[#dbe4ff] text-sm">{message}</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="rounded-[18px] bg-[#9db6ff] px-4 py-3 font-mono text-[#09111d] text-[11px] uppercase tracking-[0.24em] transition hover:bg-[#b8cbff] disabled:cursor-not-allowed disabled:bg-[#556888] disabled:text-[#d4def8]"
|
||||||
|
disabled={createPost.isPending}
|
||||||
|
type="submit"
|
||||||
|
>
|
||||||
|
{createPost.isPending ? "Creating..." : "Create post"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
27
src/components/PostList.tsx
Normal file
27
src/components/PostList.tsx
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { PostCard } from "~/components/PostCard";
|
||||||
|
import type { RouterOutputs } from "~/utils/api";
|
||||||
|
|
||||||
|
type PostListProps = {
|
||||||
|
emptyTitle: string;
|
||||||
|
emptyText: string;
|
||||||
|
posts: RouterOutputs["post"]["list"];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PostList = ({ emptyText, emptyTitle, posts }: PostListProps) => {
|
||||||
|
if (posts.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-[28px] border border-white/16 border-dashed bg-white/[0.04] px-6 py-12 text-center">
|
||||||
|
<h2 className="text-2xl tracking-[-0.04em]">{emptyTitle}</h2>
|
||||||
|
<p className="mx-auto mt-4 max-w-xl text-[#c2cee8] text-sm leading-7">{emptyText}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid gap-4 lg:grid-cols-2 xl:grid-cols-3">
|
||||||
|
{posts.map((post) => (
|
||||||
|
<PostCard key={post.id} post={post} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
20
src/env.js
Normal file
20
src/env.js
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { createEnv } from "@t3-oss/env-nextjs";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export const env = createEnv({
|
||||||
|
server: {
|
||||||
|
DATABASE_URL: z.url(),
|
||||||
|
BETTER_AUTH_SECRET: z.string().trim().min(32),
|
||||||
|
BETTER_AUTH_URL: z.url(),
|
||||||
|
NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
|
||||||
|
},
|
||||||
|
client: {},
|
||||||
|
runtimeEnv: {
|
||||||
|
DATABASE_URL: process.env.DATABASE_URL,
|
||||||
|
BETTER_AUTH_SECRET: process.env.BETTER_AUTH_SECRET,
|
||||||
|
BETTER_AUTH_URL: process.env.BETTER_AUTH_URL,
|
||||||
|
NODE_ENV: process.env.NODE_ENV,
|
||||||
|
},
|
||||||
|
skipValidation: !!process.env.SKIP_ENV_VALIDATION,
|
||||||
|
emptyStringAsUndefined: true,
|
||||||
|
});
|
||||||
27
src/pages/_app.tsx
Normal file
27
src/pages/_app.tsx
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import type { AppType } from "next/app";
|
||||||
|
import { IBM_Plex_Mono, Space_Grotesk } from "next/font/google";
|
||||||
|
|
||||||
|
import { api } from "~/utils/api";
|
||||||
|
|
||||||
|
import "~/styles/globals.css";
|
||||||
|
|
||||||
|
const spaceGrotesk = Space_Grotesk({
|
||||||
|
subsets: ["latin"],
|
||||||
|
variable: "--font-space-grotesk",
|
||||||
|
});
|
||||||
|
|
||||||
|
const ibmPlexMono = IBM_Plex_Mono({
|
||||||
|
subsets: ["latin"],
|
||||||
|
variable: "--font-ibm-plex-mono",
|
||||||
|
weight: ["400", "500"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const MyApp: AppType = ({ Component, pageProps }) => {
|
||||||
|
return (
|
||||||
|
<div className={`${spaceGrotesk.variable} ${ibmPlexMono.variable} font-sans`}>
|
||||||
|
<Component {...pageProps} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default api.withTRPC(MyApp);
|
||||||
110
src/pages/account.tsx
Normal file
110
src/pages/account.tsx
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
import type { GetServerSideProps } from "next";
|
||||||
|
import Head from "next/head";
|
||||||
|
import { useRouter } from "next/router";
|
||||||
|
import { startTransition, useState } from "react";
|
||||||
|
|
||||||
|
import { PageShell } from "~/components/PageShell";
|
||||||
|
import { requireAuthenticatedPage } from "~/server/page-auth";
|
||||||
|
import { signOut, useSession } from "~/utils/auth-client";
|
||||||
|
import { formatDateTime } from "~/utils/formatting";
|
||||||
|
|
||||||
|
const actionClassName =
|
||||||
|
"rounded-full bg-[#9db6ff] px-4 py-3 font-mono text-[11px] uppercase tracking-[0.24em] text-[#09111d] transition hover:bg-[#b8cbff] disabled:cursor-not-allowed disabled:bg-[#556888] disabled:text-[#d4def8]";
|
||||||
|
|
||||||
|
export default function AccountPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const { data: session, isPending: isSessionPending } = useSession();
|
||||||
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||||
|
const [isSigningOut, setIsSigningOut] = useState(false);
|
||||||
|
|
||||||
|
const handleSignOut = async () => {
|
||||||
|
setErrorMessage(null);
|
||||||
|
setIsSigningOut(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await signOut();
|
||||||
|
startTransition(() => {
|
||||||
|
void router.replace("/");
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
setErrorMessage(error instanceof Error ? error.message : "Unable to sign out.");
|
||||||
|
} finally {
|
||||||
|
setIsSigningOut(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Head>
|
||||||
|
<title>Account | Web Boilerplate</title>
|
||||||
|
<meta content="Protected account route for the Better Auth session." name="description" />
|
||||||
|
</Head>
|
||||||
|
|
||||||
|
<PageShell
|
||||||
|
actions={
|
||||||
|
<button
|
||||||
|
className={actionClassName}
|
||||||
|
disabled={isSigningOut || isSessionPending}
|
||||||
|
onClick={() => void handleSignOut()}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{isSigningOut ? "Signing out..." : "Sign out"}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
eyebrow="Protected Route"
|
||||||
|
intro="This page is only reachable with a valid Better Auth session and mirrors the current session state directly from the client."
|
||||||
|
title="Account"
|
||||||
|
>
|
||||||
|
{errorMessage ? (
|
||||||
|
<div className="mb-6 rounded-[24px] border border-[#6f4348] bg-[#231115] px-5 py-4 text-[#ffccd2] text-sm">{errorMessage}</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<section className="rounded-[30px] border border-white/12 bg-white/[0.05] p-6 shadow-[0_20px_80px_rgba(2,6,23,0.28)] md:p-8">
|
||||||
|
{session ? (
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<div className="rounded-[24px] border border-white/10 bg-[#0b1320]/80 p-5">
|
||||||
|
<p className="font-mono text-[#8ea5dd] text-[11px] uppercase tracking-[0.24em]">Profile</p>
|
||||||
|
<dl className="mt-4 space-y-4 text-[#d5def2] text-sm">
|
||||||
|
<div>
|
||||||
|
<dt className="font-mono text-[#8f9ab6] text-[11px] uppercase tracking-[0.22em]">Name</dt>
|
||||||
|
<dd className="mt-1">{session.user.name}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="font-mono text-[#8f9ab6] text-[11px] uppercase tracking-[0.22em]">Email</dt>
|
||||||
|
<dd className="mt-1">{session.user.email}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="font-mono text-[#8f9ab6] text-[11px] uppercase tracking-[0.22em]">Verified</dt>
|
||||||
|
<dd className="mt-1">{session.user.emailVerified ? "Yes" : "No"}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-[24px] border border-white/10 bg-[#0b1320]/80 p-5">
|
||||||
|
<p className="font-mono text-[#8ea5dd] text-[11px] uppercase tracking-[0.24em]">Session</p>
|
||||||
|
<dl className="mt-4 space-y-4 text-[#d5def2] text-sm">
|
||||||
|
<div>
|
||||||
|
<dt className="font-mono text-[#8f9ab6] text-[11px] uppercase tracking-[0.22em]">Session ID</dt>
|
||||||
|
<dd className="mt-1 break-all">{session.session.id}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="font-mono text-[#8f9ab6] text-[11px] uppercase tracking-[0.22em]">Expires</dt>
|
||||||
|
<dd className="mt-1">{formatDateTime(session.session.expiresAt)}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="font-mono text-[#8f9ab6] text-[11px] uppercase tracking-[0.22em]">User ID</dt>
|
||||||
|
<dd className="mt-1 break-all">{session.user.id}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="h-56 animate-pulse rounded-[24px] border border-white/10 bg-white/[0.05]" />
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</PageShell>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getServerSideProps: GetServerSideProps = requireAuthenticatedPage;
|
||||||
11
src/pages/api/auth/[...all].ts
Normal file
11
src/pages/api/auth/[...all].ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { toNodeHandler } from "better-auth/node";
|
||||||
|
|
||||||
|
import { auth } from "~/server/auth";
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
api: {
|
||||||
|
bodyParser: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default toNodeHandler(auth);
|
||||||
16
src/pages/api/trpc/[trpc].ts
Normal file
16
src/pages/api/trpc/[trpc].ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { createNextApiHandler } from "@trpc/server/adapters/next";
|
||||||
|
|
||||||
|
import { env } from "~/env";
|
||||||
|
import { appRouter } from "~/server/api/root";
|
||||||
|
import { createTRPCContext } from "~/server/api/trpc";
|
||||||
|
|
||||||
|
export default createNextApiHandler({
|
||||||
|
router: appRouter,
|
||||||
|
createContext: createTRPCContext,
|
||||||
|
onError:
|
||||||
|
env.NODE_ENV === "development"
|
||||||
|
? ({ path, error }) => {
|
||||||
|
console.error(`❌ tRPC failed on ${path ?? "<no-path>"}: ${error.message}`);
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
});
|
||||||
71
src/pages/dashboard.tsx
Normal file
71
src/pages/dashboard.tsx
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import type { GetServerSideProps } from "next";
|
||||||
|
import Head from "next/head";
|
||||||
|
|
||||||
|
import { PageShell } from "~/components/PageShell";
|
||||||
|
import { PostForm } from "~/components/PostForm";
|
||||||
|
import { PostList } from "~/components/PostList";
|
||||||
|
import { requireAuthenticatedPage } from "~/server/page-auth";
|
||||||
|
import { api } from "~/utils/api";
|
||||||
|
import { useSession } from "~/utils/auth-client";
|
||||||
|
|
||||||
|
const dashboardSkeletonKeys = ["mine-loading-1", "mine-loading-2"] as const;
|
||||||
|
|
||||||
|
export default function DashboardPage() {
|
||||||
|
const { data: session } = useSession();
|
||||||
|
const myPostsQuery = api.post.mine.useQuery();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Head>
|
||||||
|
<title>Dashboard | Web Boilerplate</title>
|
||||||
|
<meta content="Protected dashboard route for post creation." name="description" />
|
||||||
|
</Head>
|
||||||
|
|
||||||
|
<PageShell
|
||||||
|
eyebrow="Protected Route"
|
||||||
|
intro="This page is guarded by `getServerSideProps` and uses a protected tRPC query for the current user's posts."
|
||||||
|
title={session ? `Dashboard for ${session.user.name}` : "Dashboard"}
|
||||||
|
>
|
||||||
|
<div className="grid gap-6 lg:grid-cols-[0.95fr_1.05fr]">
|
||||||
|
<PostForm />
|
||||||
|
|
||||||
|
<section className="rounded-[28px] border border-white/12 bg-white/[0.05] p-6 shadow-[0_18px_70px_rgba(2,6,23,0.24)]">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<p className="font-mono text-[#99b6ff] text-[11px] uppercase tracking-[0.28em]">Protected read route</p>
|
||||||
|
<h2 className="text-2xl tracking-[-0.04em]">Your posts</h2>
|
||||||
|
<p className="text-[#c5d0ea] text-sm leading-7">
|
||||||
|
This panel calls `post.mine`, which only resolves when a valid session is present.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6">
|
||||||
|
{myPostsQuery.error ? (
|
||||||
|
<div className="rounded-[18px] border border-[#6f4348] bg-[#231115] px-4 py-3 text-[#ffccd2] text-sm">
|
||||||
|
Failed to load your posts: {myPostsQuery.error.message}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{myPostsQuery.isPending ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{dashboardSkeletonKeys.map((key) => (
|
||||||
|
<div className="h-40 animate-pulse rounded-[24px] border border-white/10 bg-white/[0.05]" key={key} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{myPostsQuery.data ? (
|
||||||
|
<PostList
|
||||||
|
emptyText="You have access to the dashboard, but you have not created any posts yet."
|
||||||
|
emptyTitle="No personal posts yet"
|
||||||
|
posts={myPostsQuery.data}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</PageShell>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getServerSideProps: GetServerSideProps = requireAuthenticatedPage;
|
||||||
131
src/pages/index.tsx
Normal file
131
src/pages/index.tsx
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
import Head from "next/head";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
import { PageShell } from "~/components/PageShell";
|
||||||
|
import { PostList } from "~/components/PostList";
|
||||||
|
import { api } from "~/utils/api";
|
||||||
|
import { useSession } from "~/utils/auth-client";
|
||||||
|
|
||||||
|
const featureCards = [
|
||||||
|
{
|
||||||
|
label: "Public routes",
|
||||||
|
title: "Posts are readable without auth",
|
||||||
|
description: "The home page, posts index, and post detail route are intentionally open so a new app has a public surface immediately.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Protected routes",
|
||||||
|
title: "Writes require Better Auth",
|
||||||
|
description: "Dashboard, account, post creation, and comment creation all depend on a valid Better Auth email/password session.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Typed server",
|
||||||
|
title: "tRPC and Prisma stay thin",
|
||||||
|
description:
|
||||||
|
"Data fetching stays in typed routers and services, leaving the page layer focused on small UI states and empty-state handling.",
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const previewSkeletonKeys = ["home-loading-1", "home-loading-2", "home-loading-3"] as const;
|
||||||
|
|
||||||
|
export default function HomePage() {
|
||||||
|
const { data: session } = useSession();
|
||||||
|
const postsQuery = api.post.list.useQuery({ limit: 3 });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Head>
|
||||||
|
<title>Web Boilerplate</title>
|
||||||
|
<meta content="Generic Next.js boilerplate with Better Auth, tRPC, and Prisma." name="description" />
|
||||||
|
</Head>
|
||||||
|
|
||||||
|
<PageShell
|
||||||
|
actions={
|
||||||
|
<>
|
||||||
|
<Link
|
||||||
|
className="rounded-full bg-[#9db6ff] px-4 py-3 font-mono text-[#09111d] text-[11px] uppercase tracking-[0.24em] transition hover:bg-[#b8cbff]"
|
||||||
|
href={session ? "/dashboard" : "/sign-up"}
|
||||||
|
>
|
||||||
|
{session ? "Open dashboard" : "Create account"}
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
className="rounded-full border border-white/12 px-4 py-3 font-mono text-[#d7e0f4] text-[11px] uppercase tracking-[0.24em] transition hover:border-white/40 hover:bg-white/8"
|
||||||
|
href="/posts"
|
||||||
|
>
|
||||||
|
Browse posts
|
||||||
|
</Link>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
eyebrow="Shared Stack"
|
||||||
|
intro="A generic Pages Router app that mirrors the reference stack without carrying over any music or audio behavior."
|
||||||
|
title="Start from typed routes, auth gates, and clean empty states"
|
||||||
|
>
|
||||||
|
<section className="grid gap-4 lg:grid-cols-[1.25fr_0.95fr]">
|
||||||
|
<div className="rounded-[30px] border border-white/12 bg-white/[0.05] p-6 shadow-[0_20px_80px_rgba(2,6,23,0.28)] md:p-8">
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<p className="font-mono text-[#99b6ff] text-[11px] uppercase tracking-[0.28em]">What is included</p>
|
||||||
|
<h2 className="text-3xl tracking-[-0.05em]">Better Auth, Prisma, tRPC, and a simple post/comment relationship</h2>
|
||||||
|
<p className="max-w-2xl text-[#c2cee8] text-sm leading-7">
|
||||||
|
Use this as the first pass for a typed internal tool or product prototype. The public routes stay open, the protected flows
|
||||||
|
stay strict, and the database starts with two clear relational models.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-8 grid gap-4 md:grid-cols-3">
|
||||||
|
{featureCards.map((card) => (
|
||||||
|
<article className="rounded-[24px] border border-white/10 bg-[#0b1320]/80 p-5" key={card.title}>
|
||||||
|
<p className="font-mono text-[#8ea5dd] text-[11px] uppercase tracking-[0.24em]">{card.label}</p>
|
||||||
|
<h3 className="mt-3 text-xl tracking-[-0.04em]">{card.title}</h3>
|
||||||
|
<p className="mt-3 text-[#b9c6e4] text-sm leading-7">{card.description}</p>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-[30px] border border-white/12 bg-[#0a1220]/88 p-6 shadow-[0_20px_80px_rgba(2,6,23,0.28)] md:p-8">
|
||||||
|
<p className="font-mono text-[#99b6ff] text-[11px] uppercase tracking-[0.28em]">Current state</p>
|
||||||
|
<h2 className="mt-3 text-2xl tracking-[-0.04em]">
|
||||||
|
{session ? "You can use the protected routes now" : "Sign in only when you need protected routes"}
|
||||||
|
</h2>
|
||||||
|
<p className="mt-4 text-[#c2cee8] text-sm leading-7">
|
||||||
|
{session
|
||||||
|
? "Your session is active, so the dashboard can create posts and the post detail page can submit comments."
|
||||||
|
: "You can browse everything public first. When you need writes, sign up with Better Auth email/password and continue into the dashboard."}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mt-8 rounded-[24px] border border-white/16 border-dashed bg-white/[0.03] p-5">
|
||||||
|
<p className="font-mono text-[#8ea5dd] text-[11px] uppercase tracking-[0.24em]">Preview query</p>
|
||||||
|
<p className="mt-3 text-[#b9c6e4] text-sm leading-7">
|
||||||
|
The list below calls the public `post.list` procedure. With a fresh database, it should stay calm and render a deliberate
|
||||||
|
empty state.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="mt-8">
|
||||||
|
{postsQuery.error ? (
|
||||||
|
<div className="rounded-[24px] border border-[#6f4348] bg-[#231115] px-5 py-4 text-[#ffccd2] text-sm">
|
||||||
|
Failed to load posts: {postsQuery.error.message}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{postsQuery.isPending ? (
|
||||||
|
<div className="grid gap-4 lg:grid-cols-3">
|
||||||
|
{previewSkeletonKeys.map((key) => (
|
||||||
|
<div className="h-64 animate-pulse rounded-[26px] border border-white/10 bg-white/[0.05]" key={key} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{postsQuery.data ? (
|
||||||
|
<PostList
|
||||||
|
emptyText="There are no posts yet. Sign in, create one from the dashboard, and the public routes will update automatically."
|
||||||
|
emptyTitle="No posts have been published yet"
|
||||||
|
posts={postsQuery.data}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
</PageShell>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
139
src/pages/posts/[postId].tsx
Normal file
139
src/pages/posts/[postId].tsx
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
import type { GetServerSideProps } from "next";
|
||||||
|
import Head from "next/head";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
import { CommentForm } from "~/components/CommentForm";
|
||||||
|
import { PageShell } from "~/components/PageShell";
|
||||||
|
import { api } from "~/utils/api";
|
||||||
|
import { useSession } from "~/utils/auth-client";
|
||||||
|
import { formatDateTime } from "~/utils/formatting";
|
||||||
|
|
||||||
|
type PostDetailPageProps = {
|
||||||
|
postId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function PostDetailPage({ postId }: PostDetailPageProps) {
|
||||||
|
const { data: session, isPending: isSessionPending } = useSession();
|
||||||
|
const postQuery = api.post.byId.useQuery({ postId });
|
||||||
|
|
||||||
|
const post = postQuery.data;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Head>
|
||||||
|
<title>{post ? `${post.title} | Web Boilerplate` : "Post | Web Boilerplate"}</title>
|
||||||
|
<meta content="Public post detail route with protected comment creation." name="description" />
|
||||||
|
</Head>
|
||||||
|
|
||||||
|
<PageShell
|
||||||
|
eyebrow="Public Route"
|
||||||
|
intro="The post itself stays readable for everyone. The comment form only becomes available once Better Auth resolves a valid session."
|
||||||
|
title={post ? post.title : "Post detail"}
|
||||||
|
>
|
||||||
|
{postQuery.isPending ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="h-72 animate-pulse rounded-[28px] border border-white/10 bg-white/[0.05]" />
|
||||||
|
<div className="h-56 animate-pulse rounded-[28px] border border-white/10 bg-white/[0.05]" />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{postQuery.error ? (
|
||||||
|
<div className="rounded-[24px] border border-[#6f4348] bg-[#231115] px-5 py-4 text-[#ffccd2] text-sm">
|
||||||
|
Failed to load the post: {postQuery.error.message}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{!postQuery.isPending && !postQuery.error && !post ? (
|
||||||
|
<div className="rounded-[28px] border border-white/16 border-dashed bg-white/[0.04] px-6 py-12 text-center">
|
||||||
|
<h2 className="text-2xl tracking-[-0.04em]">This post does not exist</h2>
|
||||||
|
<p className="mx-auto mt-4 max-w-xl text-[#c2cee8] text-sm leading-7">
|
||||||
|
The route is valid, but there is no database record for this post ID. That is expected for an unknown link.
|
||||||
|
</p>
|
||||||
|
<Link className="mt-6 inline-flex font-mono text-[#9db6ff] text-[11px] uppercase tracking-[0.24em]" href="/posts">
|
||||||
|
Back to posts
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{post ? (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<article className="rounded-[30px] border border-white/12 bg-white/[0.05] p-6 shadow-[0_20px_80px_rgba(2,6,23,0.28)] md:p-8">
|
||||||
|
<div className="flex flex-wrap items-center gap-3 text-[#a5b1cf] text-sm">
|
||||||
|
<span className="rounded-full border border-white/10 px-3 py-1 font-mono text-[#8ea5dd] text-[10px] uppercase tracking-[0.22em]">
|
||||||
|
{post.author.name}
|
||||||
|
</span>
|
||||||
|
<span>{formatDateTime(post.createdAt)}</span>
|
||||||
|
<span>{post._count.comments} comments</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 whitespace-pre-wrap text-[#d5def2] text-[15px] leading-8">{post.body}</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<section className="rounded-[30px] border border-white/12 bg-[#0a1220]/88 p-6 shadow-[0_20px_80px_rgba(2,6,23,0.28)] md:p-8">
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<p className="font-mono text-[#99b6ff] text-[11px] uppercase tracking-[0.28em]">Comments</p>
|
||||||
|
<h2 className="mt-3 text-2xl tracking-[-0.04em]">Discussion on this post</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 space-y-4">
|
||||||
|
{post.comments.length === 0 ? (
|
||||||
|
<div className="rounded-[24px] border border-white/16 border-dashed bg-white/[0.04] px-5 py-8 text-center text-[#c2cee8] text-sm leading-7">
|
||||||
|
No comments yet. The first signed-in user can leave one below.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
post.comments.map((comment) => (
|
||||||
|
<article className="rounded-[24px] border border-white/10 bg-white/[0.04] p-5" key={comment.id}>
|
||||||
|
<div className="flex flex-wrap items-center gap-3 text-[#a5b1cf] text-sm">
|
||||||
|
<span className="font-mono text-[#8ea5dd] text-[11px] uppercase tracking-[0.22em]">{comment.author.name}</span>
|
||||||
|
<span>{formatDateTime(comment.createdAt)}</span>
|
||||||
|
</div>
|
||||||
|
<p className="mt-3 whitespace-pre-wrap text-[#d5def2] text-sm leading-7">{comment.body}</p>
|
||||||
|
</article>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{session ? (
|
||||||
|
<CommentForm postId={postId} />
|
||||||
|
) : isSessionPending ? (
|
||||||
|
<div className="h-52 animate-pulse rounded-[26px] border border-white/10 bg-white/[0.05]" />
|
||||||
|
) : (
|
||||||
|
<section className="rounded-[26px] border border-white/12 bg-[#0a1220]/85 p-6 text-center">
|
||||||
|
<p className="font-mono text-[#99b6ff] text-[11px] uppercase tracking-[0.24em]">Protected write route</p>
|
||||||
|
<h2 className="mt-3 text-xl tracking-[-0.04em]">Sign in to comment</h2>
|
||||||
|
<p className="mt-3 text-[#c5d0ea] text-sm leading-7">
|
||||||
|
Reading stays public, but posting a comment requires a Better Auth session.
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
className="mt-5 inline-flex rounded-full bg-[#9db6ff] px-4 py-3 font-mono text-[#09111d] text-[11px] uppercase tracking-[0.24em] transition hover:bg-[#b8cbff]"
|
||||||
|
href={`/sign-in?next=${encodeURIComponent(`/posts/${postId}`)}`}
|
||||||
|
>
|
||||||
|
Sign in to comment
|
||||||
|
</Link>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</PageShell>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getServerSideProps: GetServerSideProps<PostDetailPageProps> = async (context) => {
|
||||||
|
const postId = context.params?.postId;
|
||||||
|
|
||||||
|
if (typeof postId !== "string" || postId.trim().length === 0) {
|
||||||
|
return {
|
||||||
|
notFound: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
props: {
|
||||||
|
postId,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
55
src/pages/posts/index.tsx
Normal file
55
src/pages/posts/index.tsx
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import Head from "next/head";
|
||||||
|
|
||||||
|
import { PageShell } from "~/components/PageShell";
|
||||||
|
import { PostList } from "~/components/PostList";
|
||||||
|
import { api } from "~/utils/api";
|
||||||
|
|
||||||
|
const postSkeletonKeys = [
|
||||||
|
"posts-loading-1",
|
||||||
|
"posts-loading-2",
|
||||||
|
"posts-loading-3",
|
||||||
|
"posts-loading-4",
|
||||||
|
"posts-loading-5",
|
||||||
|
"posts-loading-6",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export default function PostsPage() {
|
||||||
|
const postsQuery = api.post.list.useQuery({ limit: 24 });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Head>
|
||||||
|
<title>Posts | Web Boilerplate</title>
|
||||||
|
<meta content="Public post index for the generic Better Auth boilerplate." name="description" />
|
||||||
|
</Head>
|
||||||
|
|
||||||
|
<PageShell
|
||||||
|
eyebrow="Public Route"
|
||||||
|
intro="This page is intentionally unprotected. It should still be useful with an empty database and predictable after new posts are created."
|
||||||
|
title="Browse the full post index"
|
||||||
|
>
|
||||||
|
{postsQuery.error ? (
|
||||||
|
<div className="rounded-[24px] border border-[#6f4348] bg-[#231115] px-5 py-4 text-[#ffccd2] text-sm">
|
||||||
|
Failed to load posts: {postsQuery.error.message}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{postsQuery.isPending ? (
|
||||||
|
<div className="grid gap-4 lg:grid-cols-2 xl:grid-cols-3">
|
||||||
|
{postSkeletonKeys.map((key) => (
|
||||||
|
<div className="h-64 animate-pulse rounded-[26px] border border-white/10 bg-white/[0.05]" key={key} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{postsQuery.data ? (
|
||||||
|
<PostList
|
||||||
|
emptyText="This route is live, but the database is still empty. Create the first post from the protected dashboard."
|
||||||
|
emptyTitle="Nothing to show yet"
|
||||||
|
posts={postsQuery.data}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</PageShell>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
27
src/pages/sign-in.tsx
Normal file
27
src/pages/sign-in.tsx
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import type { GetServerSideProps } from "next";
|
||||||
|
import Head from "next/head";
|
||||||
|
|
||||||
|
import { AuthForm } from "~/components/AuthForm";
|
||||||
|
import { PageShell } from "~/components/PageShell";
|
||||||
|
import { type AuthPageProps, redirectAuthenticatedFromAuthPages } from "~/server/page-auth";
|
||||||
|
|
||||||
|
export default function SignInPage({ nextPath }: AuthPageProps) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Head>
|
||||||
|
<title>Sign In | Web Boilerplate</title>
|
||||||
|
<meta content="Better Auth email and password sign-in route." name="description" />
|
||||||
|
</Head>
|
||||||
|
|
||||||
|
<PageShell
|
||||||
|
eyebrow="Authentication"
|
||||||
|
intro="This route stays public, but authenticated visitors are redirected back to the dashboard."
|
||||||
|
title="Sign in with Better Auth"
|
||||||
|
>
|
||||||
|
<AuthForm mode="sign-in" nextPath={nextPath} />
|
||||||
|
</PageShell>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getServerSideProps: GetServerSideProps<AuthPageProps> = redirectAuthenticatedFromAuthPages;
|
||||||
27
src/pages/sign-up.tsx
Normal file
27
src/pages/sign-up.tsx
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import type { GetServerSideProps } from "next";
|
||||||
|
import Head from "next/head";
|
||||||
|
|
||||||
|
import { AuthForm } from "~/components/AuthForm";
|
||||||
|
import { PageShell } from "~/components/PageShell";
|
||||||
|
import { type AuthPageProps, redirectAuthenticatedFromAuthPages } from "~/server/page-auth";
|
||||||
|
|
||||||
|
export default function SignUpPage({ nextPath }: AuthPageProps) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Head>
|
||||||
|
<title>Sign Up | Web Boilerplate</title>
|
||||||
|
<meta content="Better Auth email and password sign-up route." name="description" />
|
||||||
|
</Head>
|
||||||
|
|
||||||
|
<PageShell
|
||||||
|
eyebrow="Authentication"
|
||||||
|
intro="Create a user through Better Auth email and password, then land on the protected dashboard."
|
||||||
|
title="Create a Better Auth account"
|
||||||
|
>
|
||||||
|
<AuthForm mode="sign-up" nextPath={nextPath} />
|
||||||
|
</PageShell>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getServerSideProps: GetServerSideProps<AuthPageProps> = redirectAuthenticatedFromAuthPages;
|
||||||
12
src/server/api/root.ts
Normal file
12
src/server/api/root.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { commentRouter } from "~/server/api/routers/comment";
|
||||||
|
import { postRouter } from "~/server/api/routers/post";
|
||||||
|
import { createCallerFactory, createTRPCRouter } from "~/server/api/trpc";
|
||||||
|
|
||||||
|
export const appRouter = createTRPCRouter({
|
||||||
|
comment: commentRouter,
|
||||||
|
post: postRouter,
|
||||||
|
});
|
||||||
|
|
||||||
|
export type AppRouter = typeof appRouter;
|
||||||
|
|
||||||
|
export const createCaller = createCallerFactory(appRouter);
|
||||||
31
src/server/api/routers/comment.ts
Normal file
31
src/server/api/routers/comment.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { TRPCError } from "@trpc/server";
|
||||||
|
|
||||||
|
import { createCommentInputSchema } from "~/server/api/schemas/post";
|
||||||
|
import { createComment } from "~/server/api/services/comment";
|
||||||
|
import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc";
|
||||||
|
|
||||||
|
export const commentRouter = createTRPCRouter({
|
||||||
|
create: protectedProcedure.input(createCommentInputSchema).mutation(async ({ ctx, input }) => {
|
||||||
|
const post = await ctx.db.post.findUnique({
|
||||||
|
where: {
|
||||||
|
id: input.postId,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!post) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "NOT_FOUND",
|
||||||
|
message: "Post not found.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return createComment(ctx.db, {
|
||||||
|
authorId: ctx.session.user.id,
|
||||||
|
body: input.body,
|
||||||
|
postId: input.postId,
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
});
|
||||||
18
src/server/api/routers/post.ts
Normal file
18
src/server/api/routers/post.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { createPostInputSchema, getPostByIdInputSchema, listPostsInputSchema } from "~/server/api/schemas/post";
|
||||||
|
import { createPost, getPostById, listMyPosts, listPosts } from "~/server/api/services/post";
|
||||||
|
import { createTRPCRouter, protectedProcedure, publicProcedure } from "~/server/api/trpc";
|
||||||
|
|
||||||
|
export const postRouter = createTRPCRouter({
|
||||||
|
list: publicProcedure.input(listPostsInputSchema.optional()).query(({ ctx, input }) => listPosts(ctx.db, input)),
|
||||||
|
|
||||||
|
byId: publicProcedure.input(getPostByIdInputSchema).query(({ ctx, input }) => getPostById(ctx.db, input.postId)),
|
||||||
|
|
||||||
|
mine: protectedProcedure.query(({ ctx }) => listMyPosts(ctx.db, ctx.session.user.id)),
|
||||||
|
|
||||||
|
create: protectedProcedure.input(createPostInputSchema).mutation(({ ctx, input }) =>
|
||||||
|
createPost(ctx.db, {
|
||||||
|
...input,
|
||||||
|
authorId: ctx.session.user.id,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
});
|
||||||
40
src/server/api/schemas/post.ts
Normal file
40
src/server/api/schemas/post.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
const nonEmptyString = (field: string) => z.string().trim().min(1, `${field} is required`);
|
||||||
|
|
||||||
|
const blankStringToUndefined = (value: unknown) => {
|
||||||
|
if (value === null || value === undefined) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value !== "string") {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmedValue = value.trim();
|
||||||
|
return trimmedValue.length > 0 ? trimmedValue : undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const postIdSchema = nonEmptyString("Post ID");
|
||||||
|
|
||||||
|
export const listPostsInputSchema = z.object({
|
||||||
|
limit: z.coerce.number().int().min(1).max(50).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const getPostByIdInputSchema = z.object({
|
||||||
|
postId: postIdSchema,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const createPostInputSchema = z.object({
|
||||||
|
title: nonEmptyString("Title").max(160, "Title must be 160 characters or fewer"),
|
||||||
|
body: nonEmptyString("Body").max(10_000, "Body must be 10000 characters or fewer"),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const createCommentInputSchema = z.object({
|
||||||
|
postId: postIdSchema,
|
||||||
|
body: z.preprocess(blankStringToUndefined, nonEmptyString("Comment").max(4_000, "Comment must be 4000 characters or fewer")),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type ListPostsInput = z.infer<typeof listPostsInputSchema>;
|
||||||
|
export type CreatePostInput = z.infer<typeof createPostInputSchema>;
|
||||||
|
export type CreateCommentInput = z.infer<typeof createCommentInputSchema>;
|
||||||
30
src/server/api/services/comment.ts
Normal file
30
src/server/api/services/comment.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import type { Prisma, PrismaClient } from "@/db";
|
||||||
|
|
||||||
|
type DatabaseClient = Pick<PrismaClient, "comment">;
|
||||||
|
|
||||||
|
const authorSelect = {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
email: true,
|
||||||
|
} satisfies Prisma.UserSelect;
|
||||||
|
|
||||||
|
const commentSelect = {
|
||||||
|
id: true,
|
||||||
|
body: true,
|
||||||
|
createdAt: true,
|
||||||
|
updatedAt: true,
|
||||||
|
author: {
|
||||||
|
select: authorSelect,
|
||||||
|
},
|
||||||
|
} satisfies Prisma.CommentSelect;
|
||||||
|
|
||||||
|
export const createComment = (db: DatabaseClient, input: { authorId: string; body: string; postId: string }) => {
|
||||||
|
return db.comment.create({
|
||||||
|
data: {
|
||||||
|
authorId: input.authorId,
|
||||||
|
body: input.body,
|
||||||
|
postId: input.postId,
|
||||||
|
},
|
||||||
|
select: commentSelect,
|
||||||
|
});
|
||||||
|
};
|
||||||
100
src/server/api/services/post.ts
Normal file
100
src/server/api/services/post.ts
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
import type { Prisma, PrismaClient } from "@/db";
|
||||||
|
import type { CreatePostInput, ListPostsInput } from "~/server/api/schemas/post";
|
||||||
|
|
||||||
|
type DatabaseClient = Pick<PrismaClient, "post">;
|
||||||
|
|
||||||
|
const authorSelect = {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
email: true,
|
||||||
|
} satisfies Prisma.UserSelect;
|
||||||
|
|
||||||
|
const commentSelect = {
|
||||||
|
id: true,
|
||||||
|
body: true,
|
||||||
|
createdAt: true,
|
||||||
|
updatedAt: true,
|
||||||
|
author: {
|
||||||
|
select: authorSelect,
|
||||||
|
},
|
||||||
|
} satisfies Prisma.CommentSelect;
|
||||||
|
|
||||||
|
const postCardSelect = {
|
||||||
|
id: true,
|
||||||
|
title: true,
|
||||||
|
body: true,
|
||||||
|
createdAt: true,
|
||||||
|
updatedAt: true,
|
||||||
|
author: {
|
||||||
|
select: authorSelect,
|
||||||
|
},
|
||||||
|
_count: {
|
||||||
|
select: {
|
||||||
|
comments: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} satisfies Prisma.PostSelect;
|
||||||
|
|
||||||
|
const postDetailSelect = {
|
||||||
|
id: true,
|
||||||
|
title: true,
|
||||||
|
body: true,
|
||||||
|
createdAt: true,
|
||||||
|
updatedAt: true,
|
||||||
|
author: {
|
||||||
|
select: authorSelect,
|
||||||
|
},
|
||||||
|
comments: {
|
||||||
|
orderBy: {
|
||||||
|
createdAt: "asc",
|
||||||
|
},
|
||||||
|
select: commentSelect,
|
||||||
|
},
|
||||||
|
_count: {
|
||||||
|
select: {
|
||||||
|
comments: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} satisfies Prisma.PostSelect;
|
||||||
|
|
||||||
|
export const listPosts = (db: DatabaseClient, input: ListPostsInput = {}) => {
|
||||||
|
return db.post.findMany({
|
||||||
|
orderBy: {
|
||||||
|
createdAt: "desc",
|
||||||
|
},
|
||||||
|
take: input.limit ?? 12,
|
||||||
|
select: postCardSelect,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getPostById = (db: DatabaseClient, postId: string) => {
|
||||||
|
return db.post.findUnique({
|
||||||
|
where: {
|
||||||
|
id: postId,
|
||||||
|
},
|
||||||
|
select: postDetailSelect,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const listMyPosts = (db: DatabaseClient, authorId: string) => {
|
||||||
|
return db.post.findMany({
|
||||||
|
where: {
|
||||||
|
authorId,
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
createdAt: "desc",
|
||||||
|
},
|
||||||
|
select: postCardSelect,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createPost = (db: DatabaseClient, input: CreatePostInput & { authorId: string }) => {
|
||||||
|
return db.post.create({
|
||||||
|
data: {
|
||||||
|
title: input.title,
|
||||||
|
body: input.body,
|
||||||
|
authorId: input.authorId,
|
||||||
|
},
|
||||||
|
select: postCardSelect,
|
||||||
|
});
|
||||||
|
};
|
||||||
74
src/server/api/trpc.ts
Normal file
74
src/server/api/trpc.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import { initTRPC, TRPCError } from "@trpc/server";
|
||||||
|
import type { CreateNextContextOptions } from "@trpc/server/adapters/next";
|
||||||
|
import superjson from "superjson";
|
||||||
|
import { ZodError } from "zod";
|
||||||
|
import { getNodeSession } from "~/server/auth";
|
||||||
|
import { db } from "~/server/db";
|
||||||
|
|
||||||
|
type CreateContextOptions = {
|
||||||
|
headers: CreateNextContextOptions["req"]["headers"];
|
||||||
|
};
|
||||||
|
|
||||||
|
const createInnerTRPCContext = async (opts: CreateContextOptions) => {
|
||||||
|
const session = await getNodeSession(opts.headers);
|
||||||
|
|
||||||
|
return {
|
||||||
|
db,
|
||||||
|
session,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createTRPCContext = async (opts: CreateNextContextOptions) => {
|
||||||
|
return createInnerTRPCContext({
|
||||||
|
headers: opts.req.headers,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
type TRPCContext = Awaited<ReturnType<typeof createTRPCContext>>;
|
||||||
|
|
||||||
|
const t = initTRPC.context<TRPCContext>().create({
|
||||||
|
transformer: superjson,
|
||||||
|
errorFormatter({ shape, error }) {
|
||||||
|
return {
|
||||||
|
...shape,
|
||||||
|
data: {
|
||||||
|
...shape.data,
|
||||||
|
zodError: error.cause instanceof ZodError ? error.cause.flatten() : null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const createCallerFactory = t.createCallerFactory;
|
||||||
|
export const createTRPCRouter = t.router;
|
||||||
|
|
||||||
|
const timingMiddleware = t.middleware(async ({ next, path }) => {
|
||||||
|
const start = Date.now();
|
||||||
|
|
||||||
|
if (t._config.isDev) {
|
||||||
|
const waitMs = Math.floor(Math.random() * 400) + 100;
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, waitMs));
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await next();
|
||||||
|
console.log(`[TRPC] ${path} took ${Date.now() - start}ms to execute`);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
|
||||||
|
export const publicProcedure = t.procedure.use(timingMiddleware);
|
||||||
|
|
||||||
|
export const protectedProcedure = t.procedure.use(timingMiddleware).use(({ ctx, next }) => {
|
||||||
|
if (!ctx.session) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "UNAUTHORIZED",
|
||||||
|
message: "You must be signed in to perform this action.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return next({
|
||||||
|
ctx: {
|
||||||
|
session: ctx.session,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
31
src/server/auth.ts
Normal file
31
src/server/auth.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import type { IncomingHttpHeaders } from "node:http";
|
||||||
|
import { betterAuth } from "better-auth";
|
||||||
|
import { prismaAdapter } from "better-auth/adapters/prisma";
|
||||||
|
import { fromNodeHeaders } from "better-auth/node";
|
||||||
|
|
||||||
|
import { env } from "~/env";
|
||||||
|
import { db } from "~/server/db";
|
||||||
|
|
||||||
|
const buildFallbackAuthUrl = "http://localhost:3456";
|
||||||
|
const buildFallbackAuthSecret = "build-only-better-auth-secret-should-be-overridden";
|
||||||
|
|
||||||
|
const resolvedBaseUrl = process.env.BETTER_AUTH_URL ?? env.BETTER_AUTH_URL ?? buildFallbackAuthUrl;
|
||||||
|
const resolvedSecret = process.env.BETTER_AUTH_SECRET ?? env.BETTER_AUTH_SECRET ?? buildFallbackAuthSecret;
|
||||||
|
|
||||||
|
export const auth = betterAuth({
|
||||||
|
database: prismaAdapter(db, {
|
||||||
|
provider: "postgresql",
|
||||||
|
}),
|
||||||
|
emailAndPassword: {
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
baseURL: resolvedBaseUrl,
|
||||||
|
secret: resolvedSecret,
|
||||||
|
trustedOrigins: [resolvedBaseUrl],
|
||||||
|
});
|
||||||
|
|
||||||
|
export const getNodeSession = async (headers: IncomingHttpHeaders) => {
|
||||||
|
return auth.api.getSession({
|
||||||
|
headers: fromNodeHeaders(headers),
|
||||||
|
});
|
||||||
|
};
|
||||||
25
src/server/db.ts
Normal file
25
src/server/db.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { PrismaPg } from "@prisma/adapter-pg";
|
||||||
|
import { PrismaClient } from "@/db";
|
||||||
|
import { env } from "~/env";
|
||||||
|
|
||||||
|
const buildFallbackDatabaseUrl = "postgresql://postgres:postgres@localhost:5432/web_boilerplate";
|
||||||
|
|
||||||
|
const adapter = new PrismaPg({
|
||||||
|
connectionString: process.env.DATABASE_URL ?? env.DATABASE_URL ?? buildFallbackDatabaseUrl,
|
||||||
|
});
|
||||||
|
|
||||||
|
const createPrismaClient = () =>
|
||||||
|
new PrismaClient({
|
||||||
|
adapter,
|
||||||
|
log: env.NODE_ENV === "development" ? ["error", "warn"] : ["error"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const globalForPrisma = globalThis as unknown as {
|
||||||
|
prisma: ReturnType<typeof createPrismaClient> | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const db = globalForPrisma.prisma ?? createPrismaClient();
|
||||||
|
|
||||||
|
if (env.NODE_ENV !== "production") {
|
||||||
|
globalForPrisma.prisma = db;
|
||||||
|
}
|
||||||
53
src/server/page-auth.ts
Normal file
53
src/server/page-auth.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import type { GetServerSidePropsContext, GetServerSidePropsResult } from "next";
|
||||||
|
|
||||||
|
import { getNodeSession } from "~/server/auth";
|
||||||
|
import { DEFAULT_AUTH_REDIRECT, getSafeNextPath } from "~/utils/next-path";
|
||||||
|
|
||||||
|
export type AuthPageProps = {
|
||||||
|
nextPath: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildSignInDestination = (resolvedUrl: string) => {
|
||||||
|
const nextPath = getSafeNextPath(resolvedUrl, DEFAULT_AUTH_REDIRECT);
|
||||||
|
return `/sign-in?next=${encodeURIComponent(nextPath)}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const requireAuthenticatedPage = async (
|
||||||
|
context: GetServerSidePropsContext,
|
||||||
|
): Promise<GetServerSidePropsResult<Record<string, never>>> => {
|
||||||
|
const session = await getNodeSession(context.req.headers);
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
return {
|
||||||
|
redirect: {
|
||||||
|
destination: buildSignInDestination(context.resolvedUrl),
|
||||||
|
permanent: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
props: {},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const redirectAuthenticatedFromAuthPages = async (
|
||||||
|
context: GetServerSidePropsContext,
|
||||||
|
): Promise<GetServerSidePropsResult<AuthPageProps>> => {
|
||||||
|
const session = await getNodeSession(context.req.headers);
|
||||||
|
|
||||||
|
if (session) {
|
||||||
|
return {
|
||||||
|
redirect: {
|
||||||
|
destination: DEFAULT_AUTH_REDIRECT,
|
||||||
|
permanent: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
props: {
|
||||||
|
nextPath: getSafeNextPath(context.query.next),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
32
src/styles/globals.css
Normal file
32
src/styles/globals.css
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
@theme {
|
||||||
|
--font-sans: var(--font-space-grotesk), ui-sans-serif, system-ui, sans-serif;
|
||||||
|
--font-mono: var(--font-ibm-plex-mono), ui-monospace, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
min-height: 100%;
|
||||||
|
background: #050910;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
min-height: 100vh;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top, rgba(103, 145, 255, 0.18), transparent 34%),
|
||||||
|
linear-gradient(180deg, #08111b 0%, #0d1522 45%, #060b12 100%);
|
||||||
|
color: #f3f6ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
::selection {
|
||||||
|
background: rgba(157, 182, 255, 0.35);
|
||||||
|
color: #f8fbff;
|
||||||
|
}
|
||||||
39
src/utils/api.ts
Normal file
39
src/utils/api.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { httpBatchLink, loggerLink } from "@trpc/client";
|
||||||
|
import { createTRPCNext } from "@trpc/next";
|
||||||
|
import type { inferRouterInputs, inferRouterOutputs } from "@trpc/server";
|
||||||
|
import superjson from "superjson";
|
||||||
|
|
||||||
|
import type { AppRouter } from "~/server/api/root";
|
||||||
|
|
||||||
|
const getBaseUrl = () => {
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.env.VERCEL_URL) {
|
||||||
|
return `https://${process.env.VERCEL_URL}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `http://localhost:${process.env.PORT ?? 3000}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const api = createTRPCNext<AppRouter>({
|
||||||
|
config() {
|
||||||
|
return {
|
||||||
|
links: [
|
||||||
|
loggerLink({
|
||||||
|
enabled: (opts) => process.env.NODE_ENV === "development" || (opts.direction === "down" && opts.result instanceof Error),
|
||||||
|
}),
|
||||||
|
httpBatchLink({
|
||||||
|
transformer: superjson,
|
||||||
|
url: `${getBaseUrl()}/api/trpc`,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
ssr: false,
|
||||||
|
transformer: superjson,
|
||||||
|
});
|
||||||
|
|
||||||
|
export type RouterInputs = inferRouterInputs<AppRouter>;
|
||||||
|
export type RouterOutputs = inferRouterOutputs<AppRouter>;
|
||||||
5
src/utils/auth-client.ts
Normal file
5
src/utils/auth-client.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { createAuthClient } from "better-auth/react";
|
||||||
|
|
||||||
|
export const authClient = createAuthClient();
|
||||||
|
|
||||||
|
export const { signIn, signOut, signUp, useSession } = authClient;
|
||||||
14
src/utils/formatting.ts
Normal file
14
src/utils/formatting.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
export const formatDateTime = (value: Date | string) => {
|
||||||
|
return new Intl.DateTimeFormat("en", {
|
||||||
|
dateStyle: "medium",
|
||||||
|
timeStyle: "short",
|
||||||
|
}).format(new Date(value));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const truncateText = (value: string, maxLength: number) => {
|
||||||
|
if (value.length <= maxLength) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${value.slice(0, maxLength - 3).trimEnd()}...`;
|
||||||
|
};
|
||||||
14
src/utils/next-path.ts
Normal file
14
src/utils/next-path.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
export const DEFAULT_AUTH_REDIRECT = "/dashboard";
|
||||||
|
|
||||||
|
export const getSafeNextPath = (value: string | string[] | undefined, fallback = DEFAULT_AUTH_REDIRECT) => {
|
||||||
|
if (typeof value !== "string") {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedValue = value.trim();
|
||||||
|
if (!normalizedValue.startsWith("/") || normalizedValue.startsWith("//")) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalizedValue;
|
||||||
|
};
|
||||||
32
tsconfig.json
Normal file
32
tsconfig.json
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"target": "es2022",
|
||||||
|
"allowJs": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"isolatedModules": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"strict": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"checkJs": true,
|
||||||
|
"lib": ["dom", "dom.iterable", "ES2022"],
|
||||||
|
"noEmit": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "next"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"incremental": false,
|
||||||
|
"paths": {
|
||||||
|
"~/*": ["./src/*"],
|
||||||
|
"@/db": ["./generated/prisma/client"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", "**/*.cjs", "**/*.js", ".next/types/**/*.ts"],
|
||||||
|
"exclude": ["node_modules", "generated", "index.js", "eslint.config.mjs"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user