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