├── src ├── app │ ├── favicon.ico │ ├── api │ │ ├── auth │ │ │ └── [...auth] │ │ │ │ └── route.ts │ │ └── trpc │ │ │ └── [trpc] │ │ │ └── route.ts │ ├── layout.tsx │ ├── globals.css │ └── page.tsx ├── lib │ ├── utils.ts │ ├── auth-client.ts │ ├── db.ts │ ├── auth.ts │ ├── db-types.ts │ └── files.ts ├── utils │ ├── getBaseUrl.ts │ └── trpc.ts ├── components │ ├── auth │ │ ├── auth-provider.tsx │ │ └── auth-button.tsx │ ├── theme-provider.tsx │ ├── mode-toggle.tsx │ ├── ui │ │ ├── avatar.tsx │ │ ├── badge.tsx │ │ ├── button.tsx │ │ ├── card.tsx │ │ └── dropdown-menu.tsx │ ├── providers.tsx │ ├── todo-list.tsx │ └── file-upload.tsx ├── server │ ├── trpc.ts │ └── routers │ │ ├── _app.ts │ │ ├── todos.ts │ │ └── files.ts └── env.ts ├── postcss.config.mjs ├── public ├── vercel.svg ├── window.svg ├── file.svg ├── globe.svg └── next.svg ├── .kysely-codegenrc.json ├── next.config.ts ├── .cursor └── rules │ ├── pg.mdc │ └── always.mdc ├── components.json ├── eslint.config.mjs ├── prettier.config.js ├── .gitignore ├── tsconfig.json ├── atlas.hcl ├── scripts └── setup.ts ├── db └── schema.sql ├── package.json ├── README.md └── CLAUDE.md /src/app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elitan/j4pp/HEAD/src/app/favicon.ico -------------------------------------------------------------------------------- /postcss.config.mjs: -------------------------------------------------------------------------------- 1 | const config = { 2 | plugins: ["@tailwindcss/postcss"], 3 | }; 4 | 5 | export default config; 6 | -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.kysely-codegenrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "camelCase": true, 3 | "dialect": "postgres", 4 | "outFile": "./src/lib/db-types.ts", 5 | "url": "env(DATABASE_URL)" 6 | } 7 | -------------------------------------------------------------------------------- /src/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { clsx, type ClassValue } from "clsx" 2 | import { twMerge } from "tailwind-merge" 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)) 6 | } 7 | -------------------------------------------------------------------------------- /next.config.ts: -------------------------------------------------------------------------------- 1 | import { env } from "./src/env.js"; 2 | import type { NextConfig } from "next"; 3 | 4 | const nextConfig: NextConfig = { 5 | /* config options here */ 6 | }; 7 | 8 | export default nextConfig; 9 | -------------------------------------------------------------------------------- /.cursor/rules/pg.mdc: -------------------------------------------------------------------------------- 1 | --- 2 | description: 3 | globs: 4 | alwaysApply: false 5 | --- 6 | - Extract DATABASE_URL from .env file 7 | - Use psql to connect to the PostgreSQL database 8 | - If connection succeeds, proceed with database operations -------------------------------------------------------------------------------- /src/app/api/auth/[...auth]/route.ts: -------------------------------------------------------------------------------- 1 | import { auth } from "@/lib/auth"; 2 | 3 | export async function GET(request: Request) { 4 | return auth.handler(request); 5 | } 6 | 7 | export async function POST(request: Request) { 8 | return auth.handler(request); 9 | } -------------------------------------------------------------------------------- /src/utils/getBaseUrl.ts: -------------------------------------------------------------------------------- 1 | import { env } from '@/env'; 2 | 3 | export function getBaseUrl() { 4 | if (typeof window !== 'undefined') return ''; 5 | 6 | if (env.VERCEL_URL) { 7 | return `https://${env.VERCEL_URL}`; 8 | } 9 | 10 | return `http://localhost:${env.PORT ?? 3000}`; 11 | } 12 | -------------------------------------------------------------------------------- /src/components/auth/auth-provider.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { ReactNode } from "react"; 4 | 5 | // Better Auth doesn't need a provider component like Clerk 6 | // We can just use the client hooks directly 7 | export function AuthProvider({ children }: { children: ReactNode }) { 8 | return <>{children}; 9 | } -------------------------------------------------------------------------------- /src/lib/auth-client.ts: -------------------------------------------------------------------------------- 1 | import { createAuthClient } from "better-auth/react"; 2 | 3 | export const authClient = createAuthClient({ 4 | baseURL: process.env.NEXT_PUBLIC_BETTER_AUTH_URL || "http://localhost:3000", 5 | }); 6 | 7 | export const { 8 | signIn, 9 | signUp, 10 | signOut, 11 | useSession, 12 | } = authClient; -------------------------------------------------------------------------------- /src/components/theme-provider.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import * as React from 'react'; 4 | import { ThemeProvider as NextThemesProvider } from 'next-themes'; 5 | 6 | export function ThemeProvider({ 7 | children, 8 | ...props 9 | }: React.ComponentProps) { 10 | return {children}; 11 | } 12 | -------------------------------------------------------------------------------- /public/window.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/file.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/utils/trpc.ts: -------------------------------------------------------------------------------- 1 | import { httpBatchLink } from '@trpc/client'; 2 | import { createTRPCNext } from '@trpc/next'; 3 | import type { AppRouter } from '../server/routers/_app'; 4 | import { getBaseUrl } from './getBaseUrl'; 5 | 6 | export const api = createTRPCNext({ 7 | config() { 8 | return { 9 | links: [ 10 | httpBatchLink({ 11 | url: `${getBaseUrl()}/api/trpc`, 12 | }), 13 | ], 14 | }; 15 | }, 16 | ssr: false, 17 | }); 18 | -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "new-york", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "", 8 | "css": "src/app/globals.css", 9 | "baseColor": "neutral", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils", 16 | "ui": "@/components/ui", 17 | "lib": "@/lib", 18 | "hooks": "@/hooks" 19 | }, 20 | "iconLibrary": "lucide" 21 | } -------------------------------------------------------------------------------- /src/app/api/trpc/[trpc]/route.ts: -------------------------------------------------------------------------------- 1 | import { fetchRequestHandler } from '@trpc/server/adapters/fetch'; 2 | import { appRouter } from '@/server/routers/_app'; 3 | import { createTRPCContext } from '@/server/trpc'; 4 | import type { NextRequest } from 'next/server'; 5 | 6 | const handler = (req: NextRequest) => 7 | fetchRequestHandler({ 8 | endpoint: '/api/trpc', 9 | req, 10 | router: appRouter, 11 | createContext: () => createTRPCContext({ req }), 12 | }); 13 | 14 | export { handler as GET, handler as POST }; 15 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import { dirname } from 'path'; 2 | import { fileURLToPath } from 'url'; 3 | import { FlatCompat } from '@eslint/eslintrc'; 4 | 5 | const __filename = fileURLToPath(import.meta.url); 6 | const __dirname = dirname(__filename); 7 | 8 | const compat = new FlatCompat({ 9 | baseDirectory: __dirname, 10 | }); 11 | 12 | const eslintConfig = [ 13 | ...compat.extends('next/core-web-vitals', 'next/typescript'), 14 | { 15 | rules: { 16 | '@typescript-eslint/no-explicit-any': 'off', 17 | }, 18 | }, 19 | ]; 20 | 21 | export default eslintConfig; 22 | -------------------------------------------------------------------------------- /prettier.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('prettier').Config & import('prettier-plugin-tailwindcss').options} */ 2 | const config = { 3 | plugins: ["prettier-plugin-sql", "prettier-plugin-tailwindcss"], 4 | 5 | // prettier options 6 | endOfLine: "lf", 7 | singleQuote: true, 8 | jsxSingleQuote: true, 9 | tabWidth: 2, 10 | 11 | // prettier-plugin-sql options 12 | formatter: "sql-formatter", 13 | language: "postgresql", 14 | keywordCase: "lower", 15 | dataTypeCase: "lower", 16 | functionCase: "lower", 17 | identifierCase: "lower", 18 | }; 19 | 20 | export default config; 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # dependencies 2 | /node_modules 3 | /.pnp 4 | .pnp.* 5 | .yarn/* 6 | !.yarn/patches 7 | !.yarn/plugins 8 | !.yarn/releases 9 | !.yarn/versions 10 | 11 | # testing 12 | /coverage 13 | 14 | # next.js 15 | /.next/ 16 | /out/ 17 | 18 | # production 19 | /build 20 | 21 | # misc 22 | .DS_Store 23 | *.pem 24 | 25 | # debug 26 | npm-debug.log* 27 | yarn-debug.log* 28 | yarn-error.log* 29 | .pnpm-debug.log* 30 | 31 | # env files (can opt-in for committing if needed) 32 | .env* 33 | 34 | # vercel 35 | .vercel 36 | 37 | # typescript 38 | *.tsbuildinfo 39 | next-env.d.ts 40 | 41 | .clerk 42 | 43 | # clerk configuration (can include secrets) 44 | /.clerk/ 45 | -------------------------------------------------------------------------------- /src/lib/db.ts: -------------------------------------------------------------------------------- 1 | import { env } from "@/env"; 2 | import { CamelCasePlugin, Kysely, PostgresDialect } from 'kysely'; 3 | import { Pool } from 'pg'; 4 | import type { DB } from './db-types'; 5 | 6 | const globalForDb = globalThis as unknown as { 7 | db: Kysely | undefined; 8 | }; 9 | 10 | const createDatabase = (): Kysely => { 11 | const dialect = new PostgresDialect({ 12 | pool: new Pool({ 13 | connectionString: env.DATABASE_URL, 14 | }), 15 | }); 16 | 17 | return new Kysely({ 18 | dialect, 19 | plugins: [new CamelCasePlugin()], 20 | }); 21 | }; 22 | 23 | export const db = globalForDb.db ?? createDatabase(); 24 | 25 | if (env.NODE_ENV !== 'production') globalForDb.db = db; 26 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2017", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "noEmit": true, 9 | "esModuleInterop": true, 10 | "module": "esnext", 11 | "moduleResolution": "bundler", 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "jsx": "preserve", 15 | "incremental": true, 16 | "plugins": [ 17 | { 18 | "name": "next" 19 | } 20 | ], 21 | "paths": { 22 | "@/*": ["./src/*"] 23 | } 24 | }, 25 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 26 | "exclude": ["node_modules"] 27 | } 28 | -------------------------------------------------------------------------------- /src/lib/auth.ts: -------------------------------------------------------------------------------- 1 | import { betterAuth } from 'better-auth'; 2 | import { Pool } from 'pg'; 3 | import { env } from '@/env'; 4 | 5 | export const auth = betterAuth({ 6 | database: new Pool({ 7 | connectionString: env.DATABASE_URL, 8 | }), 9 | secret: env.BETTER_AUTH_SECRET, 10 | baseURL: env.BETTER_AUTH_URL, 11 | emailAndPassword: { 12 | enabled: true, 13 | requireEmailVerification: false, 14 | }, 15 | // socialProviders: { 16 | // github: { 17 | // clientId: process.env.GITHUB_CLIENT_ID || "", 18 | // clientSecret: process.env.GITHUB_CLIENT_SECRET || "", 19 | // }, 20 | // google: { 21 | // clientId: process.env.GOOGLE_CLIENT_ID || "", 22 | // clientSecret: process.env.GOOGLE_CLIENT_SECRET || "", 23 | // }, 24 | // }, 25 | }); 26 | 27 | export type Session = typeof auth.$Infer.Session; 28 | -------------------------------------------------------------------------------- /public/globe.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /atlas.hcl: -------------------------------------------------------------------------------- 1 | locals { 2 | envfile_raw = file(".env") 3 | # Join lines that don't start with a letter (continuation lines) 4 | envfile_clean = replace(local.envfile_raw, "\n([^A-Z#])", "$1") 5 | envfile = { 6 | for line in split("\n", local.envfile_clean) : 7 | split("=", line)[0] => join("=", slice(split("=", line), 1, length(split("=", line)))) 8 | if !startswith(line, "#") && length(split("=", line)) > 1 && trimspace(line) != "" 9 | } 10 | } 11 | 12 | env "local" { 13 | src = "file://db/schema.sql" 14 | url = try(local.envfile["DATABASE_URL"], getenv("DATABASE_URL")) 15 | dev = "docker://postgres/17/dev" 16 | schemas = ["public"] 17 | } 18 | 19 | env "stage" { 20 | src = "file://db/schema.sql" 21 | url = try(local.envfile["STAGING_DATABASE_URL"], getenv("STAGING_DATABASE_URL")) 22 | dev = "docker://postgres/17/dev" 23 | schemas = ["public"] 24 | } 25 | 26 | env "prod" { 27 | src = "file://db/schema.sql" 28 | url = try(local.envfile["PRODUCTION_DATABASE_URL"], getenv("PRODUCTION_DATABASE_URL")) 29 | dev = "docker://postgres/17/dev" 30 | schemas = ["public"] 31 | } -------------------------------------------------------------------------------- /src/server/trpc.ts: -------------------------------------------------------------------------------- 1 | import { initTRPC, TRPCError } from '@trpc/server'; 2 | import { auth } from '@/lib/auth'; 3 | import type { NextRequest } from 'next/server'; 4 | 5 | // Define the context type 6 | interface CreateContextOptions { 7 | req: NextRequest; 8 | } 9 | 10 | // Create context function that includes Better Auth 11 | export async function createTRPCContext(opts: CreateContextOptions) { 12 | const session = await auth.api.getSession({ 13 | headers: opts.req.headers, 14 | }); 15 | 16 | return { 17 | session, 18 | userId: session?.user?.id, 19 | user: session?.user, 20 | }; 21 | } 22 | 23 | export type Context = Awaited>; 24 | 25 | // Initialize tRPC with context 26 | const t = initTRPC.context().create(); 27 | 28 | // Base router and procedure helpers 29 | export const router = t.router; 30 | export const publicProcedure = t.procedure; 31 | 32 | // Protected procedure that requires authentication 33 | export const protectedProcedure = t.procedure.use(async ({ ctx, next }) => { 34 | if (!ctx.userId) { 35 | throw new TRPCError({ code: 'UNAUTHORIZED' }); 36 | } 37 | 38 | return next({ 39 | ctx: { 40 | ...ctx, 41 | userId: ctx.userId, 42 | user: ctx.user, 43 | }, 44 | }); 45 | }); 46 | -------------------------------------------------------------------------------- /public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/env.ts: -------------------------------------------------------------------------------- 1 | import { createEnv } from '@t3-oss/env-nextjs'; 2 | import { z } from 'zod'; 3 | 4 | export const env = createEnv({ 5 | server: { 6 | NODE_ENV: z.string().min(1), 7 | DATABASE_URL: z.string().url(), 8 | S3_ENDPOINT: z.string().url().default('http://tmp'), 9 | S3_REGION: z.string().min(1).default('auto'), // use 'local' using minio 10 | S3_ACCESS_KEY_ID: z.string().min(1).default('tmp'), 11 | S3_SECRET_ACCESS_KEY: z.string().min(1).default('tmp'), 12 | S3_BUCKET_NAME: z.string().min(1).default('tmp'), 13 | VERCEL_URL: z.string().optional(), 14 | PORT: z.string().optional(), 15 | BETTER_AUTH_URL: z.string().url(), 16 | BETTER_AUTH_SECRET: z.string().min(1), 17 | }, 18 | client: { 19 | // Nothing here just yet 20 | }, 21 | runtimeEnv: { 22 | NODE_ENV: process.env.NODE_ENV, 23 | DATABASE_URL: process.env.DATABASE_URL, 24 | S3_ENDPOINT: process.env.S3_ENDPOINT, 25 | S3_REGION: process.env.S3_REGION, 26 | S3_ACCESS_KEY_ID: process.env.S3_ACCESS_KEY_ID, 27 | S3_SECRET_ACCESS_KEY: process.env.S3_SECRET_ACCESS_KEY, 28 | S3_BUCKET_NAME: process.env.S3_BUCKET_NAME, 29 | VERCEL_URL: process.env.VERCEL_URL, 30 | PORT: process.env.PORT, 31 | BETTER_AUTH_URL: process.env.BETTER_AUTH_URL, 32 | BETTER_AUTH_SECRET: process.env.BETTER_AUTH_SECRET, 33 | }, 34 | }); 35 | -------------------------------------------------------------------------------- /src/components/mode-toggle.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import * as React from 'react'; 4 | import { Moon, Sun } from 'lucide-react'; 5 | import { useTheme } from 'next-themes'; 6 | 7 | import { Button } from '@/components/ui/button'; 8 | import { 9 | DropdownMenu, 10 | DropdownMenuContent, 11 | DropdownMenuItem, 12 | DropdownMenuTrigger, 13 | } from '@/components/ui/dropdown-menu'; 14 | 15 | export function ModeToggle() { 16 | const { setTheme } = useTheme(); 17 | 18 | return ( 19 | 20 | 21 | 26 | 27 | 28 | setTheme('light')}> 29 | Light 30 | 31 | setTheme('dark')}> 32 | Dark 33 | 34 | setTheme('system')}> 35 | System 36 | 37 | 38 | 39 | ); 40 | } 41 | -------------------------------------------------------------------------------- /src/components/ui/avatar.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as AvatarPrimitive from "@radix-ui/react-avatar" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | const Avatar = React.forwardRef< 9 | React.ElementRef, 10 | React.ComponentPropsWithoutRef 11 | >(({ className, ...props }, ref) => ( 12 | 20 | )) 21 | Avatar.displayName = AvatarPrimitive.Root.displayName 22 | 23 | const AvatarImage = React.forwardRef< 24 | React.ElementRef, 25 | React.ComponentPropsWithoutRef 26 | >(({ className, ...props }, ref) => ( 27 | 32 | )) 33 | AvatarImage.displayName = AvatarPrimitive.Image.displayName 34 | 35 | const AvatarFallback = React.forwardRef< 36 | React.ElementRef, 37 | React.ComponentPropsWithoutRef 38 | >(({ className, ...props }, ref) => ( 39 | 47 | )) 48 | AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName 49 | 50 | export { Avatar, AvatarImage, AvatarFallback } -------------------------------------------------------------------------------- /src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from 'next'; 2 | import { Geist, Geist_Mono } from 'next/font/google'; 3 | import './globals.css'; 4 | import { TRPCProvider } from '@/components/providers'; 5 | import { ThemeProvider } from '@/components/theme-provider'; 6 | import { ModeToggle } from '@/components/mode-toggle'; 7 | import { AuthProvider } from '@/components/auth/auth-provider'; 8 | import { AuthButton } from '@/components/auth/auth-button'; 9 | 10 | const geistSans = Geist({ 11 | variable: '--font-geist-sans', 12 | subsets: ['latin'], 13 | }); 14 | 15 | const geistMono = Geist_Mono({ 16 | variable: '--font-geist-mono', 17 | subsets: ['latin'], 18 | }); 19 | 20 | export const metadata: Metadata = { 21 | title: 'AI-first Starter Template', 22 | description: '', 23 | }; 24 | 25 | export default function RootLayout({ 26 | children, 27 | }: Readonly<{ 28 | children: React.ReactNode; 29 | }>) { 30 | return ( 31 | 32 | 33 | 36 | 42 |
43 | 44 | 45 |
46 | {children} 47 |
48 | 49 | 50 |
51 | ); 52 | } 53 | -------------------------------------------------------------------------------- /scripts/setup.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bun 2 | 3 | import { existsSync } from 'fs'; 4 | import { appendFileSync } from 'fs'; 5 | import { $ } from 'bun'; 6 | import { randomBytes } from 'crypto'; 7 | 8 | async function setup() { 9 | console.log('🚀 Setting up j4pp...'); 10 | 11 | // Check if .env file already exists 12 | if (existsSync('.env')) { 13 | console.log('✅ .env file already exists'); 14 | console.log('💡 Run `bun run db:setup` if you want to reapply the schema'); 15 | return; 16 | } 17 | 18 | try { 19 | // Create new Neon database 20 | console.log('🎯 Creating new Neon database...'); 21 | await $`bunx neondb -y`; 22 | console.log('✅ Database created successfully!'); 23 | 24 | // Add Better Auth environment variables 25 | console.log('🔐 Adding Better Auth configuration...'); 26 | const betterAuthSecret = randomBytes(32).toString('hex'); 27 | const betterAuthConfig = ` 28 | # Better Auth 29 | BETTER_AUTH_SECRET=${betterAuthSecret} 30 | BETTER_AUTH_URL=http://localhost:3000 31 | NEXT_PUBLIC_BETTER_AUTH_URL=http://localhost:3000 32 | `; 33 | 34 | appendFileSync('.env', betterAuthConfig); 35 | console.log('✅ Better Auth configuration added!'); 36 | 37 | // Set up the database schema and generate types 38 | console.log('🏗️ Applying database schema and generating types...'); 39 | await $`bun run db:setup`; 40 | console.log('✅ Database schema applied and types generated!'); 41 | 42 | console.log(''); 43 | console.log('🎉 Setup complete! You can now run:'); 44 | console.log(' bun run dev'); 45 | } catch (error) { 46 | console.error('❌ Setup failed:', error); 47 | process.exit(1); 48 | } 49 | } 50 | 51 | setup().catch(console.error); 52 | -------------------------------------------------------------------------------- /src/server/routers/_app.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | import { publicProcedure, protectedProcedure, router } from '../trpc'; 3 | import { filesRouter } from './files'; 4 | import { todosRouter } from './todos'; 5 | 6 | export const appRouter = router({ 7 | // File operations 8 | files: filesRouter, 9 | 10 | // Todo operations 11 | todos: todosRouter, 12 | 13 | getPublic: publicProcedure.query(() => { 14 | return { 15 | message: 'This is public data - anyone can see this!', 16 | timestamp: new Date().toISOString(), 17 | }; 18 | }), 19 | 20 | getProtected: protectedProcedure.query(async ({ ctx }) => { 21 | return { 22 | message: `This is private data for user: ${ctx.userId}`, 23 | timestamp: new Date().toISOString(), 24 | userData: { 25 | userId: ctx.userId, 26 | role: 'premium', 27 | subscription: 'active', 28 | lastLogin: new Date().toISOString(), 29 | }, 30 | }; 31 | }), 32 | 33 | getProfile: protectedProcedure.query(async ({ ctx }) => { 34 | return { 35 | userId: ctx.userId, 36 | message: `This is private data for user: ${ctx.userId}`, 37 | timestamp: new Date().toISOString(), 38 | }; 39 | }), 40 | 41 | updateProfile: protectedProcedure 42 | .input( 43 | z.object({ 44 | name: z.string().min(1, 'Name is required'), 45 | bio: z.string().optional(), 46 | }), 47 | ) 48 | .mutation(async ({ input, ctx }) => { 49 | // In a real app, you'd save this to a database 50 | return { 51 | success: true, 52 | userId: ctx.userId, 53 | updatedData: input, 54 | message: `Profile updated for user: ${ctx.userId}`, 55 | }; 56 | }), 57 | }); 58 | 59 | // export type definition of API 60 | export type AppRouter = typeof appRouter; 61 | -------------------------------------------------------------------------------- /src/components/ui/badge.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { Slot } from "@radix-ui/react-slot" 3 | import { cva, type VariantProps } from "class-variance-authority" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const badgeVariants = cva( 8 | "inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden", 9 | { 10 | variants: { 11 | variant: { 12 | default: 13 | "border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90", 14 | secondary: 15 | "border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90", 16 | destructive: 17 | "border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", 18 | outline: 19 | "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground", 20 | }, 21 | }, 22 | defaultVariants: { 23 | variant: "default", 24 | }, 25 | } 26 | ) 27 | 28 | function Badge({ 29 | className, 30 | variant, 31 | asChild = false, 32 | ...props 33 | }: React.ComponentProps<"span"> & 34 | VariantProps & { asChild?: boolean }) { 35 | const Comp = asChild ? Slot : "span" 36 | 37 | return ( 38 | 43 | ) 44 | } 45 | 46 | export { Badge, badgeVariants } 47 | -------------------------------------------------------------------------------- /db/schema.sql: -------------------------------------------------------------------------------- 1 | -- Better Auth tables 2 | create table "user" ( 3 | id text primary key, 4 | name text, 5 | email text, 6 | "emailVerified" boolean default false, 7 | image text, 8 | "createdAt" timestamptz default now(), 9 | "updatedAt" timestamptz default now() 10 | ); 11 | 12 | create table session ( 13 | id text primary key, 14 | "userId" text not null references "user" (id) on delete cascade, 15 | token text unique not null, 16 | "expiresAt" timestamptz not null, 17 | "ipAddress" text, 18 | "userAgent" text, 19 | "createdAt" timestamptz default now(), 20 | "updatedAt" timestamptz default now() 21 | ); 22 | 23 | create table account ( 24 | id text primary key, 25 | "userId" text not null references "user" (id) on delete cascade, 26 | "accountId" text not null, 27 | "providerId" text not null, 28 | "accessToken" text, 29 | "refreshToken" text, 30 | "accessTokenExpiresAt" timestamptz, 31 | "refreshTokenExpiresAt" timestamptz, 32 | scope text, 33 | "idToken" text, 34 | password text, 35 | "createdAt" timestamptz default now(), 36 | "updatedAt" timestamptz default now() 37 | ); 38 | 39 | create table verification ( 40 | id text primary key, 41 | identifier text not null, 42 | value text not null, 43 | "expiresAt" timestamptz not null, 44 | "createdAt" timestamptz default now(), 45 | "updatedAt" timestamptz default now() 46 | ); 47 | 48 | -- File storage 49 | create table files ( 50 | id uuid primary key default gen_random_uuid (), 51 | filename text not null, 52 | mime_type text not null, 53 | size bigint not null, 54 | status text not null default 'uploading', 55 | created_at timestamptz default now(), 56 | updated_at timestamptz default now(), 57 | etag text, 58 | updated_by_user_id text references "user" (id) on delete set null, 59 | metadata jsonb 60 | ); 61 | 62 | -- Demo Todo List 63 | create table todos ( 64 | id serial primary key, 65 | user_id text not null references "user" (id) on delete cascade, 66 | title text not null, 67 | completed boolean not null default false, 68 | created_at timestamptz default now(), 69 | updated_at timestamptz default now() 70 | ); 71 | -------------------------------------------------------------------------------- /src/components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { Slot } from "@radix-ui/react-slot" 3 | import { cva, type VariantProps } from "class-variance-authority" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const buttonVariants = cva( 8 | "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", 9 | { 10 | variants: { 11 | variant: { 12 | default: 13 | "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90", 14 | destructive: 15 | "bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", 16 | outline: 17 | "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50", 18 | secondary: 19 | "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80", 20 | ghost: 21 | "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", 22 | link: "text-primary underline-offset-4 hover:underline", 23 | }, 24 | size: { 25 | default: "h-9 px-4 py-2 has-[>svg]:px-3", 26 | sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5", 27 | lg: "h-10 rounded-md px-6 has-[>svg]:px-4", 28 | icon: "size-9", 29 | }, 30 | }, 31 | defaultVariants: { 32 | variant: "default", 33 | size: "default", 34 | }, 35 | } 36 | ) 37 | 38 | function Button({ 39 | className, 40 | variant, 41 | size, 42 | asChild = false, 43 | ...props 44 | }: React.ComponentProps<"button"> & 45 | VariantProps & { 46 | asChild?: boolean 47 | }) { 48 | const Comp = asChild ? Slot : "button" 49 | 50 | return ( 51 | 56 | ) 57 | } 58 | 59 | export { Button, buttonVariants } 60 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "j4pp", 3 | "version": "0.0.1", 4 | "license": "MIT", 5 | "scripts": { 6 | "setup": "bun run scripts/setup.ts", 7 | "dev": "next dev --turbopack", 8 | "build": "next build", 9 | "start": "next start", 10 | "lint": "next lint", 11 | "db:generate": "kysely-codegen", 12 | "db:check": "atlas schema apply --env local --dry-run", 13 | "db:check:stage": "atlas schema apply --env stage --dry-run", 14 | "db:check:prod": "atlas schema apply --env prod --dry-run", 15 | "db:push": "atlas schema apply --env local --auto-approve", 16 | "db:push:stage": "atlas schema apply --env stage --auto-approve", 17 | "db:push:prod": "atlas schema apply --env prod --auto-approve", 18 | "db:setup": "bun run db:push && bun run db:generate" 19 | }, 20 | "dependencies": { 21 | "@aws-sdk/client-s3": "^3.839.0", 22 | "@aws-sdk/s3-request-presigner": "^3.839.0", 23 | "@radix-ui/react-avatar": "^1.1.10", 24 | "@radix-ui/react-dropdown-menu": "^2.1.15", 25 | "@radix-ui/react-slot": "^1.2.3", 26 | "@t3-oss/env-nextjs": "^0.13.8", 27 | "@tanstack/react-query": "^5.81.2", 28 | "@trpc/client": "^11.4.2", 29 | "@trpc/next": "^11.4.2", 30 | "@trpc/react-query": "^11.4.2", 31 | "@trpc/server": "^11.4.2", 32 | "better-auth": "^1.2.12", 33 | "class-variance-authority": "^0.7.1", 34 | "clsx": "^2.1.1", 35 | "kysely": "^0.28.2", 36 | "lucide": "^0.525.0", 37 | "lucide-react": "^0.525.0", 38 | "next": "15.3.4", 39 | "next-themes": "^0.4.6", 40 | "pg": "^8.16.2", 41 | "react": "^19.0.0", 42 | "react-dom": "^19.0.0", 43 | "tailwind-merge": "^3.3.1", 44 | "zod": "^3.25.67" 45 | }, 46 | "devDependencies": { 47 | "@better-auth/cli": "^1.2.12", 48 | "@eslint/eslintrc": "^3", 49 | "@tailwindcss/postcss": "^4", 50 | "@types/bun": "^1.2.17", 51 | "@types/node": "^20", 52 | "@types/pg": "^8.15.4", 53 | "@types/react": "^19", 54 | "@types/react-dom": "^19", 55 | "@typescript/native-preview": "latest", 56 | "eslint": "^9", 57 | "eslint-config-next": "15.3.4", 58 | "kysely-codegen": "^0.18.5", 59 | "prettier": "^3.1.0", 60 | "prettier-plugin-sql": "^0.18.1", 61 | "prettier-plugin-tailwindcss": "^0.5.7", 62 | "tailwindcss": "^4", 63 | "tw-animate-css": "^1.3.4", 64 | "typescript": "^5" 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/components/ui/card.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | function Card({ className, ...props }: React.ComponentProps<"div">) { 6 | return ( 7 |
15 | ) 16 | } 17 | 18 | function CardHeader({ className, ...props }: React.ComponentProps<"div">) { 19 | return ( 20 |
28 | ) 29 | } 30 | 31 | function CardTitle({ className, ...props }: React.ComponentProps<"div">) { 32 | return ( 33 |
38 | ) 39 | } 40 | 41 | function CardDescription({ className, ...props }: React.ComponentProps<"div">) { 42 | return ( 43 |
48 | ) 49 | } 50 | 51 | function CardAction({ className, ...props }: React.ComponentProps<"div">) { 52 | return ( 53 |
61 | ) 62 | } 63 | 64 | function CardContent({ className, ...props }: React.ComponentProps<"div">) { 65 | return ( 66 |
71 | ) 72 | } 73 | 74 | function CardFooter({ className, ...props }: React.ComponentProps<"div">) { 75 | return ( 76 |
81 | ) 82 | } 83 | 84 | export { 85 | Card, 86 | CardHeader, 87 | CardFooter, 88 | CardTitle, 89 | CardAction, 90 | CardDescription, 91 | CardContent, 92 | } 93 | -------------------------------------------------------------------------------- /src/components/providers.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { getBaseUrl } from '@/utils/getBaseUrl'; 4 | import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; 5 | import { httpBatchLink } from '@trpc/client'; 6 | import { createTRPCReact } from '@trpc/react-query'; 7 | import { useState } from 'react'; 8 | import type { AppRouter } from '../server/routers/_app'; 9 | 10 | export const api = createTRPCReact(); 11 | 12 | export function TRPCProvider({ children }: { children: React.ReactNode }) { 13 | const [queryClient] = useState( 14 | () => 15 | new QueryClient({ 16 | defaultOptions: { 17 | queries: { 18 | retry: (failureCount, error: unknown) => { 19 | // Don't retry on unauthorized errors 20 | if ( 21 | error && 22 | typeof error === 'object' && 23 | 'data' in error && 24 | error.data && 25 | typeof error.data === 'object' && 26 | 'code' in error.data && 27 | error.data.code === 'UNAUTHORIZED' 28 | ) { 29 | return false; 30 | } 31 | // Default retry behavior for other errors (3 retries) 32 | return failureCount < 3; 33 | }, 34 | }, 35 | mutations: { 36 | retry: (failureCount, error: unknown) => { 37 | // Don't retry mutations on unauthorized errors 38 | if ( 39 | error && 40 | typeof error === 'object' && 41 | 'data' in error && 42 | error.data && 43 | typeof error.data === 'object' && 44 | 'code' in error.data && 45 | error.data.code === 'UNAUTHORIZED' 46 | ) { 47 | return false; 48 | } 49 | // Default retry behavior for other errors (3 retries) 50 | return failureCount < 3; 51 | }, 52 | }, 53 | }, 54 | }), 55 | ); 56 | const [trpcClient] = useState(() => 57 | api.createClient({ 58 | links: [ 59 | httpBatchLink({ 60 | url: `${getBaseUrl()}/api/trpc`, 61 | }), 62 | ], 63 | }), 64 | ); 65 | 66 | return ( 67 | 68 | {children} 69 | 70 | ); 71 | } 72 | -------------------------------------------------------------------------------- /src/components/auth/auth-button.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useSession, signIn, signOut, signUp } from '@/lib/auth-client'; 4 | import { Button } from '@/components/ui/button'; 5 | import { 6 | DropdownMenu, 7 | DropdownMenuContent, 8 | DropdownMenuItem, 9 | DropdownMenuTrigger, 10 | } from '@/components/ui/dropdown-menu'; 11 | import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; 12 | 13 | export function AuthButton() { 14 | const { data: session, isPending } = useSession(); 15 | 16 | if (isPending) { 17 | return
; 18 | } 19 | 20 | if (!session?.user) { 21 | return ( 22 | <> 23 | 34 | 46 | 47 | ); 48 | } 49 | 50 | return ( 51 | 52 | 53 | 64 | 65 | 66 | 67 |
68 |

69 | {session.user.name} 70 |

71 |

72 | {session.user.email} 73 |

74 |
75 |
76 | signOut()}>Sign out 77 |
78 |
79 | ); 80 | } 81 | -------------------------------------------------------------------------------- /src/server/routers/todos.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | import { router, protectedProcedure } from '../trpc'; 3 | import { db } from '@/lib/db'; 4 | 5 | export const todosRouter = router({ 6 | // Get all todos for the current user 7 | list: protectedProcedure.query(async ({ ctx }) => { 8 | // With Better Auth, ctx.userId is the actual user ID from the user table 9 | const todos = await db 10 | .selectFrom('todos') 11 | .selectAll() 12 | .where('userId', '=', ctx.userId!) 13 | .orderBy('createdAt', 'desc') 14 | .execute(); 15 | 16 | return todos; 17 | }), 18 | 19 | // Add a new todo 20 | add: protectedProcedure 21 | .input( 22 | z.object({ 23 | title: z.string().min(1, 'Title is required'), 24 | }), 25 | ) 26 | .mutation(async ({ input, ctx }) => { 27 | // Create the todo 28 | const todo = await db 29 | .insertInto('todos') 30 | .values({ 31 | userId: ctx.userId!, 32 | title: input.title, 33 | }) 34 | .returning(['id', 'title', 'completed', 'createdAt']) 35 | .executeTakeFirstOrThrow(); 36 | 37 | return todo; 38 | }), 39 | 40 | // Delete a todo 41 | delete: protectedProcedure 42 | .input( 43 | z.object({ 44 | id: z.number(), 45 | }), 46 | ) 47 | .mutation(async ({ input, ctx }) => { 48 | // Delete the todo (only if it belongs to the user) 49 | const result = await db 50 | .deleteFrom('todos') 51 | .where('id', '=', input.id) 52 | .where('userId', '=', ctx.userId!) 53 | .executeTakeFirst(); 54 | 55 | if (result.numDeletedRows === BigInt(0)) { 56 | throw new Error('Todo not found or not authorized'); 57 | } 58 | 59 | return { success: true }; 60 | }), 61 | 62 | // Toggle todo completion 63 | toggle: protectedProcedure 64 | .input( 65 | z.object({ 66 | id: z.number(), 67 | }), 68 | ) 69 | .mutation(async ({ input, ctx }) => { 70 | // Get the current todo 71 | const todo = await db 72 | .selectFrom('todos') 73 | .selectAll() 74 | .where('id', '=', input.id) 75 | .where('userId', '=', ctx.userId!) 76 | .executeTakeFirst(); 77 | 78 | if (!todo) { 79 | throw new Error('Todo not found or not authorized'); 80 | } 81 | 82 | // Toggle the completion status 83 | const updatedTodo = await db 84 | .updateTable('todos') 85 | .set({ 86 | completed: !todo.completed, 87 | updatedAt: new Date(), 88 | }) 89 | .where('id', '=', input.id) 90 | .where('userId', '=', ctx.userId!) 91 | .returning(['id', 'title', 'completed', 'createdAt']) 92 | .executeTakeFirstOrThrow(); 93 | 94 | return updatedTodo; 95 | }), 96 | }); 97 | -------------------------------------------------------------------------------- /src/server/routers/files.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | import { protectedProcedure, router } from '../trpc'; 3 | import { uploadFile, createDownloadUrl, deleteFile } from '@/lib/files'; 4 | import { db } from '@/lib/db'; 5 | 6 | export const filesRouter = router({ 7 | upload: protectedProcedure 8 | .input( 9 | z.object({ 10 | filename: z.string().min(1, 'Filename is required'), 11 | mimeType: z.string().min(1, 'MIME type is required'), 12 | size: z.number().positive('File size must be positive'), 13 | data: z.string().min(1, 'File data is required'), // base64 encoded file data 14 | }), 15 | ) 16 | .mutation(async ({ input, ctx }) => { 17 | if (!ctx.userId) { 18 | throw new Error('User not authenticated'); 19 | } 20 | 21 | // With Better Auth, the user ID from context is the actual user ID 22 | // No need to look up or create a mapping like with Clerk 23 | 24 | // Decode base64 data to buffer 25 | const buffer = Buffer.from(input.data, 'base64'); 26 | 27 | // Validate that the decoded size matches the expected size 28 | if (buffer.length !== input.size) { 29 | throw new Error('File size mismatch'); 30 | } 31 | 32 | // Upload the file using the files lib 33 | const fileRecord = await uploadFile({ 34 | filename: input.filename, 35 | mimeType: input.mimeType, 36 | size: input.size, 37 | buffer, 38 | userId: ctx.userId, 39 | }); 40 | 41 | return { 42 | success: true, 43 | file: fileRecord, 44 | message: 'File uploaded successfully', 45 | }; 46 | }), 47 | 48 | getDownloadUrl: protectedProcedure 49 | .input( 50 | z.object({ 51 | fileId: z.string().uuid('Invalid file ID'), 52 | expiresIn: z.number().positive().default(3600), // 1 hour default 53 | }), 54 | ) 55 | .mutation(async ({ input }) => { 56 | const downloadUrl = await createDownloadUrl( 57 | input.fileId, 58 | input.expiresIn, 59 | ); 60 | 61 | return { 62 | downloadUrl, 63 | expiresIn: input.expiresIn, 64 | }; 65 | }), 66 | 67 | delete: protectedProcedure 68 | .input( 69 | z.object({ 70 | fileId: z.string().uuid('Invalid file ID'), 71 | }), 72 | ) 73 | .mutation(async ({ input }) => { 74 | await deleteFile(input.fileId); 75 | 76 | return { 77 | success: true, 78 | message: 'File deleted successfully', 79 | }; 80 | }), 81 | 82 | getUserFiles: protectedProcedure.query(async ({ ctx }) => { 83 | if (!ctx.userId) { 84 | throw new Error('User not authenticated'); 85 | } 86 | 87 | // Get user's files 88 | const files = await db 89 | .selectFrom('files') 90 | .where('updatedByUserId', '=', ctx.userId) 91 | .where('status', '=', 'uploaded') 92 | .selectAll() 93 | .orderBy('createdAt', 'desc') 94 | .execute(); 95 | 96 | return { files }; 97 | }), 98 | }); 99 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # j4pp 2 | 3 | **Fast starter template optimized for rapid development and AI workflows.** 4 | 5 | ## Features 6 | 7 | - **Type-safe from DB to frontend** - Database → tRPC → React with auto-generated types 8 | - **Declarative schema** - Atlas manages migrations from `db/schema.sql` 9 | - **Zero-config setup** - One command creates DB, applies schema, generates types 10 | - **Modern stack** - Next.js 15, tRPC, Kysely, TypeScript, Tailwind 11 | 12 | ## Philosophy 13 | 14 | This template is built on a core belief: **modern development should be fast, type-safe, and AI-native.** Every tool in this stack was chosen to reduce boilerplate, eliminate entire classes of bugs, and create a seamless feedback loop from the database to the UI. 15 | 16 | - **Kysely over Prisma/Drizzle:** We chose Kysely for its unparalleled type-safety and its lightweight, SQL-first approach. It doesn't try to abstract away SQL; it embraces it, giving you full control while ensuring your queries are 100% type-safe. This makes database interactions predictable and easy to debug. 17 | 18 | - **tRPC over REST/GraphQL:** tRPC provides end-to-end type-safety without the need for code generation or schemas. Your API becomes as easy to consume as calling a function, with full autocompletion and type-checking from the backend to the frontend. This dramatically speeds up feature development. 19 | 20 | - **Atlas for Declarative Migrations:** Managing database schemas should be simple. With Atlas, you define your desired schema in a single `schema.sql` file. Atlas intelligently figures out the migration plan, making schema changes trivial and predictable. This is especially powerful for AI-driven development, where an agent can safely propose and apply schema changes. 21 | 22 | ## Quick Start 23 | 24 | ```bash 25 | # One command setup (creates Neon DB + applies schema + generates types) 26 | bun run setup 27 | 28 | # Start development 29 | bun run dev 30 | ``` 31 | 32 | ## Stack 33 | 34 | - **Database**: PostgreSQL (Neon) 35 | - **Schema**: Atlas (declarative migrations) 36 | - **Backend**: Next.js API routes + tRPC 37 | - **Frontend**: React 19 + Next.js 15 38 | - **Types**: Auto-generated with Kysely 39 | - **Auth**: Better Auth 40 | 41 | ## Type Flow 42 | 43 | ``` 44 | Database Schema (schema.sql) 45 | ↓ Atlas applies 46 | PostgreSQL 47 | ↓ Kysely generates 48 | TypeScript types 49 | ↓ tRPC uses 50 | Type-safe API 51 | ↓ React consumes 52 | Frontend components 53 | ``` 54 | 55 | ## Development Commands 56 | 57 | ```bash 58 | bun run setup # Full setup (DB + schema + types) 59 | bun run dev # Start development server 60 | bun run db:setup # Apply schema + generate types 61 | bun run db:push # Apply schema to database 62 | bun run db:generate # Generate types from database 63 | ``` 64 | 65 | ## Database Changes 66 | 67 | 1. Edit `db/schema.sql` 68 | 2. Run `bun run db:setup` 69 | 3. Types are automatically updated across your app 70 | 71 | The setup is optimized for AI tools - types flow automatically and the declarative schema makes database changes predictable and safe. 72 | -------------------------------------------------------------------------------- /src/lib/db-types.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This file was generated by kysely-codegen. 3 | * Please do not edit it manually. 4 | */ 5 | 6 | import type { ColumnType } from "kysely"; 7 | 8 | export type Generated = T extends ColumnType 9 | ? ColumnType 10 | : ColumnType; 11 | 12 | export type Int8 = ColumnType; 13 | 14 | export type Json = JsonValue; 15 | 16 | export type JsonArray = JsonValue[]; 17 | 18 | export type JsonObject = { 19 | [x: string]: JsonValue | undefined; 20 | }; 21 | 22 | export type JsonPrimitive = boolean | number | string | null; 23 | 24 | export type JsonValue = JsonArray | JsonObject | JsonPrimitive; 25 | 26 | export type Numeric = ColumnType; 27 | 28 | export type Timestamp = ColumnType; 29 | 30 | export interface Account { 31 | accessToken: string | null; 32 | accessTokenExpiresAt: Timestamp | null; 33 | accountId: string; 34 | createdAt: Generated; 35 | id: string; 36 | idToken: string | null; 37 | password: string | null; 38 | providerId: string; 39 | refreshToken: string | null; 40 | refreshTokenExpiresAt: Timestamp | null; 41 | scope: string | null; 42 | updatedAt: Generated; 43 | userId: string; 44 | } 45 | 46 | export interface Companies { 47 | createdAt: Generated; 48 | id: Generated; 49 | name: string; 50 | updatedAt: Generated; 51 | website: string | null; 52 | } 53 | 54 | export interface Contacts { 55 | companyId: number | null; 56 | createdAt: Generated; 57 | email: string | null; 58 | firstName: string | null; 59 | id: Generated; 60 | lastName: string | null; 61 | phone: string | null; 62 | updatedAt: Generated; 63 | } 64 | 65 | export interface Deals { 66 | companyId: number | null; 67 | contactId: number | null; 68 | createdAt: Generated; 69 | id: Generated; 70 | stage: Generated; 71 | title: string; 72 | updatedAt: Generated; 73 | value: Numeric | null; 74 | } 75 | 76 | export interface Files { 77 | createdAt: Generated; 78 | etag: string | null; 79 | filename: string; 80 | id: Generated; 81 | metadata: Json | null; 82 | mimeType: string; 83 | size: Int8; 84 | status: Generated; 85 | updatedAt: Generated; 86 | updatedByUserId: string | null; 87 | } 88 | 89 | export interface Session { 90 | createdAt: Generated; 91 | expiresAt: Timestamp; 92 | id: string; 93 | ipAddress: string | null; 94 | token: string; 95 | updatedAt: Generated; 96 | userAgent: string | null; 97 | userId: string; 98 | } 99 | 100 | export interface Todos { 101 | completed: Generated; 102 | createdAt: Generated; 103 | id: Generated; 104 | title: string; 105 | updatedAt: Generated; 106 | userId: string; 107 | } 108 | 109 | export interface User { 110 | createdAt: Generated; 111 | email: string | null; 112 | emailVerified: Generated; 113 | id: string; 114 | image: string | null; 115 | name: string | null; 116 | updatedAt: Generated; 117 | } 118 | 119 | export interface Verification { 120 | createdAt: Generated; 121 | expiresAt: Timestamp; 122 | id: string; 123 | identifier: string; 124 | updatedAt: Generated; 125 | value: string; 126 | } 127 | 128 | export interface DB { 129 | account: Account; 130 | companies: Companies; 131 | contacts: Contacts; 132 | deals: Deals; 133 | files: Files; 134 | session: Session; 135 | todos: Todos; 136 | user: User; 137 | verification: Verification; 138 | } 139 | -------------------------------------------------------------------------------- /src/lib/files.ts: -------------------------------------------------------------------------------- 1 | import { env } from '@/env'; 2 | import { db } from '@/lib/db'; 3 | import type { Files } from '@/lib/db-types'; 4 | import type { Selectable } from 'kysely'; 5 | import { 6 | S3Client, 7 | PutObjectCommand, 8 | GetObjectCommand, 9 | DeleteObjectCommand, 10 | } from '@aws-sdk/client-s3'; 11 | import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; 12 | 13 | // S3 Client configuration 14 | const s3Client = new S3Client({ 15 | endpoint: env.S3_ENDPOINT, 16 | region: env.S3_REGION, 17 | credentials: { 18 | accessKeyId: env.S3_ACCESS_KEY_ID, 19 | secretAccessKey: env.S3_SECRET_ACCESS_KEY, 20 | }, 21 | forcePathStyle: true, // Required for S3-compatible services like Cloudflare R2 22 | }); 23 | 24 | export interface FileUpload { 25 | filename: string; 26 | mimeType: string; 27 | size: number; 28 | buffer: Buffer; 29 | userId: string; 30 | } 31 | 32 | // Use the generated Files type for selected records 33 | export type FileRecord = Selectable; 34 | 35 | /** 36 | * Upload a file to S3 and create database record 37 | */ 38 | export async function uploadFile(upload: FileUpload) { 39 | // First create the database record to get the UUID 40 | const fileRecord = await db 41 | .insertInto('files') 42 | .values({ 43 | filename: upload.filename, 44 | mimeType: upload.mimeType, 45 | size: upload.size, 46 | status: 'uploading', 47 | updatedByUserId: upload.userId, 48 | }) 49 | .returningAll() 50 | .executeTakeFirst(); 51 | 52 | if (!fileRecord) { 53 | throw new Error('Failed to create file record'); 54 | } 55 | 56 | // Upload to S3 using the file ID as the key 57 | const uploadCommand = new PutObjectCommand({ 58 | Bucket: env.S3_BUCKET_NAME, 59 | Key: fileRecord.id, 60 | Body: upload.buffer, 61 | ContentType: upload.mimeType, 62 | Metadata: { 63 | originalFilename: upload.filename, 64 | userId: upload.userId.toString(), 65 | }, 66 | }); 67 | 68 | const result = await s3Client.send(uploadCommand); 69 | 70 | // Update the file record with success status and etag 71 | const updatedRecord = await db 72 | .updateTable('files') 73 | .set({ 74 | status: 'uploaded', 75 | etag: result.ETag?.replace(/"/g, ''), // Remove quotes from etag 76 | updatedAt: new Date(), 77 | }) 78 | .where('id', '=', fileRecord.id) 79 | .returningAll() 80 | .executeTakeFirst(); 81 | 82 | if (!updatedRecord) { 83 | throw new Error('Failed to update file record'); 84 | } 85 | 86 | return updatedRecord; 87 | } 88 | 89 | /** 90 | * Create a presigned download URL for secure file access 91 | */ 92 | export async function createDownloadUrl( 93 | fileId: string, 94 | expiresIn: number = 3600, 95 | ): Promise { 96 | // Verify user has access to the file 97 | const fileRecord = await db 98 | .selectFrom('files') 99 | .where('id', '=', fileId) 100 | .where('status', '=', 'uploaded') 101 | .selectAll() 102 | .executeTakeFirst(); 103 | 104 | if (!fileRecord) { 105 | throw new Error('File not found or access denied'); 106 | } 107 | 108 | const command = new GetObjectCommand({ 109 | Bucket: env.S3_BUCKET_NAME, 110 | Key: fileId, 111 | ResponseContentDisposition: `attachment; filename="${fileRecord.filename}"`, 112 | }); 113 | 114 | return await getSignedUrl(s3Client, command, { expiresIn }); 115 | } 116 | 117 | /** 118 | * Delete a file from both S3 and database 119 | */ 120 | export async function deleteFile(fileId: string): Promise { 121 | // Verify user owns the file 122 | const fileRecord = await db 123 | .selectFrom('files') 124 | .where('id', '=', fileId) 125 | .selectAll() 126 | .executeTakeFirst(); 127 | 128 | if (!fileRecord) { 129 | throw new Error('File not found or access denied'); 130 | } 131 | 132 | // Delete from S3 133 | const deleteCommand = new DeleteObjectCommand({ 134 | Bucket: env.S3_BUCKET_NAME, 135 | Key: fileId, 136 | }); 137 | 138 | await s3Client.send(deleteCommand); 139 | 140 | // Delete from database 141 | await db.deleteFrom('files').where('id', '=', fileId).execute(); 142 | } 143 | -------------------------------------------------------------------------------- /src/app/globals.css: -------------------------------------------------------------------------------- 1 | @layer theme, base, components, utilities; 2 | @import 'tailwindcss'; 3 | @import "tw-animate-css"; 4 | 5 | @custom-variant dark (&:is(.dark *)); 6 | 7 | @theme inline { 8 | --color-background: var(--background); 9 | --color-foreground: var(--foreground); 10 | --font-sans: var(--font-geist-sans); 11 | --font-mono: var(--font-geist-mono); 12 | --color-sidebar-ring: var(--sidebar-ring); 13 | --color-sidebar-border: var(--sidebar-border); 14 | --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); 15 | --color-sidebar-accent: var(--sidebar-accent); 16 | --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); 17 | --color-sidebar-primary: var(--sidebar-primary); 18 | --color-sidebar-foreground: var(--sidebar-foreground); 19 | --color-sidebar: var(--sidebar); 20 | --color-chart-5: var(--chart-5); 21 | --color-chart-4: var(--chart-4); 22 | --color-chart-3: var(--chart-3); 23 | --color-chart-2: var(--chart-2); 24 | --color-chart-1: var(--chart-1); 25 | --color-ring: var(--ring); 26 | --color-input: var(--input); 27 | --color-border: var(--border); 28 | --color-destructive: var(--destructive); 29 | --color-accent-foreground: var(--accent-foreground); 30 | --color-accent: var(--accent); 31 | --color-muted-foreground: var(--muted-foreground); 32 | --color-muted: var(--muted); 33 | --color-secondary-foreground: var(--secondary-foreground); 34 | --color-secondary: var(--secondary); 35 | --color-primary-foreground: var(--primary-foreground); 36 | --color-primary: var(--primary); 37 | --color-popover-foreground: var(--popover-foreground); 38 | --color-popover: var(--popover); 39 | --color-card-foreground: var(--card-foreground); 40 | --color-card: var(--card); 41 | --radius-sm: calc(var(--radius) - 4px); 42 | --radius-md: calc(var(--radius) - 2px); 43 | --radius-lg: var(--radius); 44 | --radius-xl: calc(var(--radius) + 4px); 45 | } 46 | 47 | :root { 48 | --radius: 0.625rem; 49 | --background: oklch(1 0 0); 50 | --foreground: oklch(0.145 0 0); 51 | --card: oklch(1 0 0); 52 | --card-foreground: oklch(0.145 0 0); 53 | --popover: oklch(1 0 0); 54 | --popover-foreground: oklch(0.145 0 0); 55 | --primary: oklch(0.205 0 0); 56 | --primary-foreground: oklch(0.985 0 0); 57 | --secondary: oklch(0.97 0 0); 58 | --secondary-foreground: oklch(0.205 0 0); 59 | --muted: oklch(0.97 0 0); 60 | --muted-foreground: oklch(0.556 0 0); 61 | --accent: oklch(0.97 0 0); 62 | --accent-foreground: oklch(0.205 0 0); 63 | --destructive: oklch(0.577 0.245 27.325); 64 | --border: oklch(0.922 0 0); 65 | --input: oklch(0.922 0 0); 66 | --ring: oklch(0.708 0 0); 67 | --chart-1: oklch(0.646 0.222 41.116); 68 | --chart-2: oklch(0.6 0.118 184.704); 69 | --chart-3: oklch(0.398 0.07 227.392); 70 | --chart-4: oklch(0.828 0.189 84.429); 71 | --chart-5: oklch(0.769 0.188 70.08); 72 | --sidebar: oklch(0.985 0 0); 73 | --sidebar-foreground: oklch(0.145 0 0); 74 | --sidebar-primary: oklch(0.205 0 0); 75 | --sidebar-primary-foreground: oklch(0.985 0 0); 76 | --sidebar-accent: oklch(0.97 0 0); 77 | --sidebar-accent-foreground: oklch(0.205 0 0); 78 | --sidebar-border: oklch(0.922 0 0); 79 | --sidebar-ring: oklch(0.708 0 0); 80 | } 81 | 82 | .dark { 83 | --background: oklch(0.145 0 0); 84 | --foreground: oklch(0.985 0 0); 85 | --card: oklch(0.205 0 0); 86 | --card-foreground: oklch(0.985 0 0); 87 | --popover: oklch(0.205 0 0); 88 | --popover-foreground: oklch(0.985 0 0); 89 | --primary: oklch(0.922 0 0); 90 | --primary-foreground: oklch(0.205 0 0); 91 | --secondary: oklch(0.269 0 0); 92 | --secondary-foreground: oklch(0.985 0 0); 93 | --muted: oklch(0.269 0 0); 94 | --muted-foreground: oklch(0.708 0 0); 95 | --accent: oklch(0.269 0 0); 96 | --accent-foreground: oklch(0.985 0 0); 97 | --destructive: oklch(0.704 0.191 22.216); 98 | --border: oklch(1 0 0 / 10%); 99 | --input: oklch(1 0 0 / 15%); 100 | --ring: oklch(0.556 0 0); 101 | --chart-1: oklch(0.488 0.243 264.376); 102 | --chart-2: oklch(0.696 0.17 162.48); 103 | --chart-3: oklch(0.769 0.188 70.08); 104 | --chart-4: oklch(0.627 0.265 303.9); 105 | --chart-5: oklch(0.645 0.246 16.439); 106 | --sidebar: oklch(0.205 0 0); 107 | --sidebar-foreground: oklch(0.985 0 0); 108 | --sidebar-primary: oklch(0.488 0.243 264.376); 109 | --sidebar-primary-foreground: oklch(0.985 0 0); 110 | --sidebar-accent: oklch(0.269 0 0); 111 | --sidebar-accent-foreground: oklch(0.985 0 0); 112 | --sidebar-border: oklch(1 0 0 / 10%); 113 | --sidebar-ring: oklch(0.556 0 0); 114 | } 115 | 116 | @layer base { 117 | * { 118 | @apply border-border outline-ring/50; 119 | } 120 | body { 121 | @apply bg-background text-foreground; 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /CLAUDE.md: -------------------------------------------------------------------------------- 1 | # CLAUDE.md 2 | 3 | This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. 4 | 5 | ## Project Overview 6 | 7 | **j4pp** is a modern, type-safe full-stack web application template optimized for rapid development and AI workflows. The stack provides end-to-end type safety from database to frontend with declarative schema management. 8 | 9 | ## Key Commands 10 | 11 | ```bash 12 | # Development 13 | bun run dev # Start development server with Turbopack 14 | bun run build # Build for production 15 | bun run start # Start production server 16 | bun run lint # Run ESLint 17 | 18 | # Database Operations - Two-step workflow 19 | bun run db:check # Preview schema changes (dry run) - local 20 | bun run db:check:stage # Preview schema changes - staging 21 | bun run db:check:prod # Preview schema changes - production 22 | bun run db:push # Apply schema to database - local 23 | bun run db:push:stage # Apply schema to database - staging 24 | bun run db:push:prod # Apply schema to database - production 25 | bun run db:generate # Generate TypeScript types from database 26 | bun run db:setup # Apply schema + generate types (one command) 27 | 28 | # Setup 29 | bun run setup # Full project setup (DB + schema + types) 30 | ``` 31 | 32 | ## Architecture & Type Safety Flow 33 | 34 | ``` 35 | Database Schema (db/schema.sql) 36 | ↓ Atlas applies migrations 37 | PostgreSQL Database 38 | ↓ Kysely generates types 39 | TypeScript Database Types (src/lib/db-types.ts) 40 | ↓ tRPC uses for API 41 | Type-safe API Routes 42 | ↓ React components consume 43 | Frontend with full type safety 44 | ``` 45 | 46 | ## Core Technologies 47 | 48 | - **Database**: PostgreSQL with Kysely ORM for type-safe queries 49 | - **Schema Management**: Atlas (declarative migrations from `db/schema.sql`) 50 | - **API**: tRPC for end-to-end type safety 51 | - **Frontend**: Next.js 15, React 19, TypeScript 52 | - **Authentication**: Clerk 53 | - **Styling**: Tailwind CSS, Shadcn/UI components 54 | 55 | ## Database Development Workflow 56 | 57 | 1. **Edit Schema**: Modify `db/schema.sql` (declarative schema) 58 | 2. **Check Changes**: Run `bun run db:check` to preview what Atlas will apply 59 | 3. **Apply Changes**: Run `bun run db:push` to apply schema to database 60 | 4. **Generate Types**: Run `bun run db:generate` to update TypeScript types 61 | 5. **Or use**: `bun run db:setup` to apply + generate in one step 62 | 63 | **Important**: `src/lib/db-types.ts` is auto-generated - never edit manually. This file contains all database table definitions as TypeScript interfaces. 64 | 65 | ## Database Operations with Kysely 66 | 67 | Import `db` from `src/lib/db.ts` and use with generated types: 68 | 69 | ```typescript 70 | import { db } from '@/lib/db'; 71 | 72 | // Select operations 73 | const users = await db.selectFrom('users').selectAll().execute(); 74 | const user = await db 75 | .selectFrom('users') 76 | .where('id', '=', userId) 77 | .selectAll() 78 | .executeTakeFirst(); 79 | 80 | // Insert operations 81 | const newUser = await db 82 | .insertInto('users') 83 | .values({ clerk_user_id: clerkId }) 84 | .returningAll() 85 | .executeTakeFirst(); 86 | ``` 87 | 88 | ## API Development with tRPC 89 | 90 | Create procedures in `src/server/routers/` and export from `src/server/routers/_app.ts`: 91 | 92 | ```typescript 93 | export const appRouter = router({ 94 | getUsers: publicProcedure.query(async () => { 95 | return await db.selectFrom('users').selectAll().execute(); 96 | }), 97 | 98 | createUser: protectedProcedure 99 | .input(z.object({ clerkUserId: z.string() })) 100 | .mutation(async ({ input }) => { 101 | return await db 102 | .insertInto('users') 103 | .values({ clerk_user_id: input.clerkUserId }) 104 | .returningAll() 105 | .executeTakeFirst(); 106 | }), 107 | }); 108 | ``` 109 | 110 | ## Frontend Patterns 111 | 112 | - Use tRPC hooks: `api.procedureName.useQuery()` or `api.procedureName.useMutation()` 113 | - Functional components (not arrow functions) 114 | - Clerk components for authentication (``, ``) 115 | - Tailwind CSS for styling 116 | 117 | ## Current Database Schema 118 | 119 | The database includes tables for: 120 | - `users` - User management with Clerk integration 121 | - `files` - File upload system with S3 integration 122 | - `todos` - Todo list functionality 123 | - `companies`, `contacts`, `deals` - CRM functionality 124 | 125 | ## Environment Configuration 126 | 127 | Atlas uses multiple environments defined in `atlas.hcl`: 128 | - `local` - Uses `DATABASE_URL` from `.env` 129 | - `stage` - Uses `STAGING_DATABASE_URL` 130 | - `prod` - Uses `PRODUCTION_DATABASE_URL` 131 | 132 | Required environment variables: 133 | - `DATABASE_URL` 134 | - `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY` 135 | - `CLERK_SECRET_KEY` 136 | 137 | ## Development Guidelines 138 | 139 | 1. **Database Changes**: Always use the two-step workflow (check → push) for safety 140 | 2. **Type Safety**: Reference `src/lib/db-types.ts` for current database types 141 | 3. **Authentication**: Use `protectedProcedure` for auth-required endpoints 142 | 4. **Components**: Follow existing patterns in `src/components/ui/` 143 | 5. **File Structure**: Pages in `src/app/`, reusable components in `src/components/` 144 | 145 | ## Package Management 146 | 147 | This project uses **Bun** as the package manager and runtime. All commands should be run with `bun run` rather than `npm run`. -------------------------------------------------------------------------------- /src/app/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { api } from '@/components/providers'; 4 | import { Button } from '@/components/ui/button'; 5 | import { 6 | Card, 7 | CardContent, 8 | CardDescription, 9 | CardHeader, 10 | CardTitle, 11 | } from '@/components/ui/card'; 12 | import { Badge } from '@/components/ui/badge'; 13 | import { FileUpload } from '@/components/file-upload'; 14 | import { TodoList } from '@/components/todo-list'; 15 | 16 | export default function Home() { 17 | // tRPC queries 18 | const publicQuery = api.getPublic.useQuery(); 19 | const protectedQuery = api.getProtected.useQuery(undefined, { 20 | enabled: false, // triggered manually 21 | }); 22 | 23 | return ( 24 |
25 |
26 | {/* Hero */} 27 |
28 |

29 | j4pp 30 |

31 |

32 | Fast starter template optimized for rapid AI-first development 33 |

34 |

35 | Type-safe from database to frontend 36 |

37 |
38 | 39 | {/* tRPC Demo */} 40 |
41 | {/* Public Data */} 42 | 43 | 44 |
45 | 46 | Public Data 47 |
48 | Anyone can access this endpoint 49 |
50 | 51 | {publicQuery.isLoading && ( 52 |
Loading...
53 | )} 54 | 55 | {publicQuery.data && ( 56 |
57 |
58 |
59 | {publicQuery.data.message} 60 |
61 |
62 |
63 | )} 64 |
65 |
66 | 67 | {/* Protected Data */} 68 | 69 | 70 |
71 | 75 | Protected Data 76 |
77 | 78 | Requires authentication via Clerk 79 | 80 |
81 | 82 | 89 | 90 | {protectedQuery.isLoading && ( 91 |
Loading...
92 | )} 93 | 94 | {protectedQuery.isError && ( 95 |
96 | Error: {protectedQuery.error.message} 97 |
98 | )} 99 | 100 | {protectedQuery.data && ( 101 |
102 |
103 | {JSON.stringify(protectedQuery.data, null, 2)} 104 |
105 |
106 | )} 107 |
108 |
109 |
110 | 111 | {/* Todo List */} 112 |
113 |
114 |

115 | Todo List 116 |

117 |

118 | Manage your todos with tRPC and authentication 119 |

120 |
121 | 122 |
123 | 124 | {/* File Upload Demo */} 125 |
126 |
127 |

128 | File Upload Demo 129 |

130 |

131 | Upload files to S3 using tRPC and our files library 132 |

133 |
134 | 135 |
136 | 137 | {/* Quick Start */} 138 |
139 |

140 | Quick Start 141 |

142 | 143 | 144 |
145 |
146 | # Setup database and generate types 147 |
148 |
bun run setup
149 |
# Start development
150 |
bun run dev
151 |
152 |
153 |
154 |
155 | 156 | {/* Footer */} 157 |
158 |

159 | Next.js 15 • tRPC • Kysely • TypeScript • Tailwind • Clerk • 160 | Postgres • Cursor • Bun • Shadcn/UI 161 |

162 |
163 |
164 |
165 | ); 166 | } 167 | -------------------------------------------------------------------------------- /src/components/todo-list.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useState } from 'react'; 4 | import { useSession } from '@/lib/auth-client'; 5 | import { api } from '@/components/providers'; 6 | import { Button } from '@/components/ui/button'; 7 | import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; 8 | import { Badge } from '@/components/ui/badge'; 9 | import { Trash2, Plus, CheckCircle, Circle } from 'lucide-react'; 10 | 11 | export function TodoList() { 12 | const { data: session, isPending } = useSession(); 13 | const [newTodo, setNewTodo] = useState(''); 14 | const isSignedIn = !!session?.user; 15 | 16 | // tRPC queries and mutations 17 | const todosQuery = api.todos.list.useQuery(undefined, { 18 | enabled: isSignedIn, 19 | }); 20 | 21 | const addTodoMutation = api.todos.add.useMutation({ 22 | onSuccess: () => { 23 | todosQuery.refetch(); 24 | setNewTodo(''); 25 | }, 26 | }); 27 | 28 | const deleteTodoMutation = api.todos.delete.useMutation({ 29 | onSuccess: () => { 30 | todosQuery.refetch(); 31 | }, 32 | }); 33 | 34 | const toggleTodoMutation = api.todos.toggle.useMutation({ 35 | onSuccess: () => { 36 | todosQuery.refetch(); 37 | }, 38 | }); 39 | 40 | function handleAddTodo() { 41 | if (newTodo.trim()) { 42 | addTodoMutation.mutate({ title: newTodo.trim() }); 43 | } 44 | } 45 | 46 | function handleDeleteTodo(id: number) { 47 | deleteTodoMutation.mutate({ id }); 48 | } 49 | 50 | function handleToggleTodo(id: number) { 51 | toggleTodoMutation.mutate({ id }); 52 | } 53 | 54 | function handleKeyPress(e: React.KeyboardEvent) { 55 | if (e.key === 'Enter') { 56 | handleAddTodo(); 57 | } 58 | } 59 | 60 | // Show loading state while auth is loading 61 | if (isPending) { 62 | return ( 63 | 64 | 65 |
66 | 67 | Todo List 68 |
69 |
70 | 71 |
Loading...
72 |
73 |
74 | ); 75 | } 76 | 77 | // Show sign-in message for non-authenticated users 78 | if (!isSignedIn) { 79 | return ( 80 | 81 | 82 |
83 | 84 | Todo List 85 |
86 |
87 | 88 |
89 |

You need to sign in to view your todos.

90 |
91 |
92 |
93 | ); 94 | } 95 | 96 | return ( 97 | 98 | 99 |
100 | 101 | Todo List 102 |
103 |
104 | 105 | {/* Add new todo */} 106 |
107 | setNewTodo(e.target.value)} 112 | onKeyPress={handleKeyPress} 113 | className='border-input bg-background text-foreground placeholder:text-muted-foreground focus:ring-ring flex-1 rounded-md border px-3 py-2 focus:outline-none focus:ring-2 focus:ring-offset-2' 114 | disabled={addTodoMutation.isPending} 115 | /> 116 | 123 |
124 | 125 | {/* Todo list */} 126 |
127 | {todosQuery.isLoading && ( 128 |
129 | Loading todos... 130 |
131 | )} 132 | 133 | {todosQuery.isError && ( 134 |
135 | Error loading todos: {todosQuery.error.message} 136 |
137 | )} 138 | 139 | {todosQuery.data && todosQuery.data.length === 0 && ( 140 |
141 | No todos yet. Add one above! 142 |
143 | )} 144 | 145 | {todosQuery.data?.map((todo) => ( 146 |
150 | 161 | 162 | 169 | {todo.title} 170 | 171 | 172 | 179 |
180 | ))} 181 |
182 |
183 |
184 | ); 185 | } 186 | -------------------------------------------------------------------------------- /src/components/file-upload.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useState, useRef } from 'react'; 4 | import { api } from '@/utils/trpc'; 5 | import { Button } from '@/components/ui/button'; 6 | import { 7 | Card, 8 | CardContent, 9 | CardDescription, 10 | CardHeader, 11 | CardTitle, 12 | } from '@/components/ui/card'; 13 | import { Badge } from '@/components/ui/badge'; 14 | 15 | interface SelectedFile { 16 | file: File; 17 | data: string; 18 | } 19 | 20 | function formatFileSize(bytes: number): string { 21 | if (bytes === 0) return '0 Bytes'; 22 | const k = 1024; 23 | const sizes = ['Bytes', 'KB', 'MB', 'GB']; 24 | const i = Math.floor(Math.log(bytes) / Math.log(k)); 25 | return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; 26 | } 27 | 28 | function formatDate(date: Date | string): string { 29 | return new Date(date).toLocaleDateString('en-US', { 30 | year: 'numeric', 31 | month: 'short', 32 | day: 'numeric', 33 | hour: '2-digit', 34 | minute: '2-digit', 35 | }); 36 | } 37 | 38 | export function FileUpload() { 39 | const [selectedFile, setSelectedFile] = useState(null); 40 | const [isUploading, setIsUploading] = useState(false); 41 | const [uploadMessage, setUploadMessage] = useState(''); 42 | const [uploadError, setUploadError] = useState(''); 43 | const fileInputRef = useRef(null); 44 | 45 | // tRPC hooks 46 | const uploadMutation = api.files.upload.useMutation(); 47 | const { data: userFiles, refetch: refetchFiles } = 48 | api.files.getUserFiles.useQuery(); 49 | const deleteMutation = api.files.delete.useMutation(); 50 | const getDownloadUrlMutation = api.files.getDownloadUrl.useMutation(); 51 | 52 | const handleFileSelect = (event: React.ChangeEvent) => { 53 | const file = event.target.files?.[0]; 54 | if (!file) return; 55 | 56 | // Check file size (limit to 10MB for demo) 57 | if (file.size > 10 * 1024 * 1024) { 58 | setUploadError('File size must be less than 10MB'); 59 | return; 60 | } 61 | 62 | setUploadError(''); 63 | setUploadMessage(''); 64 | 65 | // Convert file to base64 66 | const reader = new FileReader(); 67 | reader.onload = () => { 68 | const result = reader.result as string; 69 | const base64Data = result.split(',')[1]; // Remove data:mime;base64, prefix 70 | 71 | setSelectedFile({ 72 | file, 73 | data: base64Data, 74 | }); 75 | }; 76 | reader.readAsDataURL(file); 77 | }; 78 | 79 | const handleUpload = async () => { 80 | if (!selectedFile) return; 81 | 82 | setIsUploading(true); 83 | setUploadError(''); 84 | setUploadMessage(''); 85 | 86 | try { 87 | const result = await uploadMutation.mutateAsync({ 88 | filename: selectedFile.file.name, 89 | mimeType: selectedFile.file.type, 90 | size: selectedFile.file.size, 91 | data: selectedFile.data, 92 | }); 93 | 94 | setUploadMessage(result.message); 95 | setSelectedFile(null); 96 | 97 | // Clear the file input 98 | if (fileInputRef.current) { 99 | fileInputRef.current.value = ''; 100 | } 101 | 102 | // Refetch user files to show the new upload 103 | await refetchFiles(); 104 | } catch (error) { 105 | setUploadError(error instanceof Error ? error.message : 'Upload failed'); 106 | } finally { 107 | setIsUploading(false); 108 | } 109 | }; 110 | 111 | const handleDelete = async (fileId: string) => { 112 | try { 113 | await deleteMutation.mutateAsync({ fileId }); 114 | await refetchFiles(); 115 | } catch (error) { 116 | console.error('Delete failed:', error); 117 | } 118 | }; 119 | 120 | const handleDownload = async (fileId: string, filename: string) => { 121 | try { 122 | const result = await getDownloadUrlMutation.mutateAsync({ fileId }); 123 | if (result.downloadUrl) { 124 | // Create a temporary link to trigger download 125 | const link = document.createElement('a'); 126 | link.href = result.downloadUrl; 127 | link.download = filename; 128 | document.body.appendChild(link); 129 | link.click(); 130 | document.body.removeChild(link); 131 | } 132 | } catch (error) { 133 | console.error('Download failed:', error); 134 | } 135 | }; 136 | 137 | return ( 138 |
139 | {/* Upload Section */} 140 | 141 | 142 | File Upload 143 | 144 | Upload files to your secure storage. Maximum file size: 10MB 145 | 146 | 147 | 148 |
149 | 155 |
156 | 157 | {selectedFile && ( 158 |
159 |

{selectedFile.file.name}

160 |

161 | {formatFileSize(selectedFile.file.size)} •{' '} 162 | {selectedFile.file.type} 163 |

164 |
165 | )} 166 | 167 | 174 | 175 | {uploadMessage && ( 176 |
177 |

{uploadMessage}

178 |
179 | )} 180 | 181 | {uploadError && ( 182 |
183 |

{uploadError}

184 |
185 | )} 186 |
187 |
188 | 189 | {/* Files List */} 190 | 191 | 192 | Your Files 193 | Manage your uploaded files 194 | 195 | 196 | {userFiles?.files.length === 0 ? ( 197 |

198 | No files uploaded yet 199 |

200 | ) : ( 201 |
202 | {userFiles?.files.map((file) => ( 203 |
207 |
208 |
209 |

{file.filename}

210 | {file.status} 211 |
212 |
213 | {formatFileSize(Number(file.size))} 214 | {file.mimeType} 215 | {formatDate(file.createdAt!)} 216 |
217 |
218 |
219 | 227 | 235 |
236 |
237 | ))} 238 |
239 | )} 240 |
241 |
242 |
243 | ); 244 | } 245 | -------------------------------------------------------------------------------- /src/components/ui/dropdown-menu.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu" 5 | import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react" 6 | 7 | import { cn } from "@/lib/utils" 8 | 9 | function DropdownMenu({ 10 | ...props 11 | }: React.ComponentProps) { 12 | return 13 | } 14 | 15 | function DropdownMenuPortal({ 16 | ...props 17 | }: React.ComponentProps) { 18 | return ( 19 | 20 | ) 21 | } 22 | 23 | function DropdownMenuTrigger({ 24 | ...props 25 | }: React.ComponentProps) { 26 | return ( 27 | 31 | ) 32 | } 33 | 34 | function DropdownMenuContent({ 35 | className, 36 | sideOffset = 4, 37 | ...props 38 | }: React.ComponentProps) { 39 | return ( 40 | 41 | 50 | 51 | ) 52 | } 53 | 54 | function DropdownMenuGroup({ 55 | ...props 56 | }: React.ComponentProps) { 57 | return ( 58 | 59 | ) 60 | } 61 | 62 | function DropdownMenuItem({ 63 | className, 64 | inset, 65 | variant = "default", 66 | ...props 67 | }: React.ComponentProps & { 68 | inset?: boolean 69 | variant?: "default" | "destructive" 70 | }) { 71 | return ( 72 | 82 | ) 83 | } 84 | 85 | function DropdownMenuCheckboxItem({ 86 | className, 87 | children, 88 | checked, 89 | ...props 90 | }: React.ComponentProps) { 91 | return ( 92 | 101 | 102 | 103 | 104 | 105 | 106 | {children} 107 | 108 | ) 109 | } 110 | 111 | function DropdownMenuRadioGroup({ 112 | ...props 113 | }: React.ComponentProps) { 114 | return ( 115 | 119 | ) 120 | } 121 | 122 | function DropdownMenuRadioItem({ 123 | className, 124 | children, 125 | ...props 126 | }: React.ComponentProps) { 127 | return ( 128 | 136 | 137 | 138 | 139 | 140 | 141 | {children} 142 | 143 | ) 144 | } 145 | 146 | function DropdownMenuLabel({ 147 | className, 148 | inset, 149 | ...props 150 | }: React.ComponentProps & { 151 | inset?: boolean 152 | }) { 153 | return ( 154 | 163 | ) 164 | } 165 | 166 | function DropdownMenuSeparator({ 167 | className, 168 | ...props 169 | }: React.ComponentProps) { 170 | return ( 171 | 176 | ) 177 | } 178 | 179 | function DropdownMenuShortcut({ 180 | className, 181 | ...props 182 | }: React.ComponentProps<"span">) { 183 | return ( 184 | 192 | ) 193 | } 194 | 195 | function DropdownMenuSub({ 196 | ...props 197 | }: React.ComponentProps) { 198 | return 199 | } 200 | 201 | function DropdownMenuSubTrigger({ 202 | className, 203 | inset, 204 | children, 205 | ...props 206 | }: React.ComponentProps & { 207 | inset?: boolean 208 | }) { 209 | return ( 210 | 219 | {children} 220 | 221 | 222 | ) 223 | } 224 | 225 | function DropdownMenuSubContent({ 226 | className, 227 | ...props 228 | }: React.ComponentProps) { 229 | return ( 230 | 238 | ) 239 | } 240 | 241 | export { 242 | DropdownMenu, 243 | DropdownMenuPortal, 244 | DropdownMenuTrigger, 245 | DropdownMenuContent, 246 | DropdownMenuGroup, 247 | DropdownMenuLabel, 248 | DropdownMenuItem, 249 | DropdownMenuCheckboxItem, 250 | DropdownMenuRadioGroup, 251 | DropdownMenuRadioItem, 252 | DropdownMenuSeparator, 253 | DropdownMenuShortcut, 254 | DropdownMenuSub, 255 | DropdownMenuSubTrigger, 256 | DropdownMenuSubContent, 257 | } 258 | -------------------------------------------------------------------------------- /.cursor/rules/always.mdc: -------------------------------------------------------------------------------- 1 | --- 2 | description: 3 | globs: 4 | alwaysApply: false 5 | --- 6 | # j4pp - AI Assistant Overview 7 | 8 | ## Project Overview 9 | 10 | **j4pp** is a modern, type-safe full-stack web application template optimized for rapid development and AI workflows. It provides a complete foundation for building web applications with end-to-end type safety from database to frontend. 11 | 12 | ## Tech Stack 13 | 14 | - **Frontend**: Next.js 15, React 19, TypeScript, Tailwind CSS 15 | - **Backend**: Next.js API routes, tRPC for type-safe APIs 16 | - **Database**: PostgreSQL (Neon), Kysely ORM 17 | - **Schema Management**: Atlas (declarative migrations) 18 | - **Authentication**: Clerk 19 | - **State Management**: TanStack Query (React Query) 20 | - **UI Components**: Shadcn/UI, Radix UI 21 | - **Package Manager**: Bun 22 | - **Runtime**: Node.js 23 | 24 | ## Architecture Overview 25 | 26 | ### Type Safety Flow 27 | 28 | ``` 29 | Database Schema (db/schema.sql) 30 | ↓ Atlas applies migrations 31 | PostgreSQL Database 32 | ↓ Kysely generates types 33 | TypeScript Database Types (src/lib/db-types.ts) 34 | ↓ tRPC uses for API 35 | Type-safe API Routes 36 | ↓ React components consume 37 | Frontend with full type safety 38 | ``` 39 | 40 | ### Key Directories 41 | 42 | - `src/app/` - Next.js App Router pages and API routes 43 | - `src/components/` - React components and UI library 44 | - `src/server/` - tRPC server setup and routers 45 | - `src/lib/` - Database connection and utilities 46 | - `db/` - Database schema and migrations 47 | - `scripts/` - Setup and utility scripts 48 | 49 | ## Development Patterns 50 | 51 | ### 1. Database Changes 52 | 53 | **Location**: `db/schema.sql` 54 | **Process**: 55 | 56 | 1. Edit the declarative schema in `db/schema.sql` 57 | 2. **Check changes**: Run `bun run db:check` to preview what will be applied 58 | 3. **Apply changes**: Run `bun run db:push` to apply the schema changes 59 | 4. **Generate types**: Run `bun run db:generate` to regenerate TypeScript types 60 | 5. Or use `bun run db:setup` to apply changes and regenerate types in one step 61 | 62 | **Two-Step Database Workflow**: 63 | 64 | ```bash 65 | # Step 1: Check what will change (dry run) 66 | bun run db:check # Local database 67 | bun run db:check:stage # Staging database 68 | bun run db:check:prod # Production database 69 | 70 | # Step 2: Apply the changes 71 | bun run db:push # Local database 72 | bun run db:push:stage # Staging database 73 | bun run db:push:prod # Production database 74 | ``` 75 | 76 | **Advanced Workflow with Plans**: 77 | 78 | ```bash 79 | # Create a migration plan 80 | bun run db:plan # Creates plan file for local database 81 | bun run db:plan:stage # Creates plan file for staging database 82 | bun run db:plan:prod # Creates plan file for production database 83 | 84 | # Apply using the plan 85 | atlas schema apply --env local --plan path/to/generated/plan.hcl 86 | ``` 87 | 88 | **Example Schema**: 89 | 90 | ```sql 91 | create table users (id serial primary key, clerk_user_id text); 92 | ``` 93 | 94 | ### 2. Database Operations with Kysely 95 | 96 | **Location**: `src/lib/db.ts` (database connection), use in tRPC procedures 97 | **Types Source**: `src/lib/db-types.ts` - **This is the source of truth for all database types** 98 | **Patterns**: 99 | 100 | - Import the `db` instance from `src/lib/db.ts` 101 | - Use generated types from `src/lib/db-types.ts` - **always reference this file for current table/column types** 102 | - Full type safety with autocomplete for table names and columns 103 | - Support for complex queries, joins, and transactions 104 | 105 | **Important**: The `src/lib/db-types.ts` file is auto-generated by Kysely and contains all your database table definitions as TypeScript interfaces. When you modify `db/schema.sql` and run `bun run db:setup`, this file is automatically updated. **Never edit this file manually** - it's your source of truth for all database types. 106 | 107 | **Example Database Operations**: 108 | 109 | ```typescript 110 | import { db } from '@/lib/db'; 111 | 112 | // Simple queries 113 | const allUsers = await db.selectFrom('users').selectAll().execute(); 114 | const user = await db 115 | .selectFrom('users') 116 | .where('id', '=', 1) 117 | .selectAll() 118 | .executeTakeFirst(); 119 | 120 | // Insert operations 121 | const newUser = await db 122 | .insertInto('users') 123 | .values({ 124 | clerkUserId: 'clerk_user_123', 125 | }) 126 | .returningAll() 127 | .executeTakeFirst(); 128 | 129 | // Update operations 130 | const updatedUser = await db 131 | .updateTable('users') 132 | .set({ clerkUserId: 'new_clerk_id' }) 133 | .where('id', '=', 1) 134 | .returningAll() 135 | .executeTakeFirst(); 136 | 137 | // Delete operations 138 | await db.deleteFrom('users').where('id', '=', 1).execute(); 139 | 140 | // Complex queries with joins (when you have multiple tables) 141 | const userWithPosts = await db 142 | .selectFrom('users') 143 | .innerJoin('posts', 'users.id', 'posts.userId') 144 | .select(['users.id', 'users.clerkUserId', 'posts.title']) 145 | .where('users.id', '=', 1) 146 | .execute(); 147 | ``` 148 | 149 | ### 3. API Development 150 | 151 | **Location**: `src/server/routers/_app.ts` 152 | **Patterns**: 153 | 154 | - Use `publicProcedure` for unauthenticated endpoints 155 | - Use `protectedProcedure` for authenticated endpoints 156 | - Input validation with Zod schemas 157 | - Full type safety from database to frontend 158 | - Combine with Kysely for database operations 159 | 160 | **Example Router with Database Operations**: 161 | 162 | ```typescript 163 | import { db } from '@/lib/db'; 164 | import { z } from 'zod'; 165 | import { publicProcedure, protectedProcedure, router } from '../trpc'; 166 | 167 | export const appRouter = router({ 168 | getPublic: publicProcedure.query(() => { 169 | return { message: 'Public data' }; 170 | }), 171 | 172 | getProtected: protectedProcedure.query(async ({ ctx }) => { 173 | return { userId: ctx.userId, data: 'Private data' }; 174 | }), 175 | 176 | // Database operations examples 177 | getAllUsers: publicProcedure.query(async () => { 178 | const users = await db.selectFrom('users').selectAll().execute(); 179 | return users; 180 | }), 181 | 182 | getUserById: publicProcedure 183 | .input(z.object({ id: z.number() })) 184 | .query(async ({ input }) => { 185 | const user = await db 186 | .selectFrom('users') 187 | .where('id', '=', input.id) 188 | .selectAll() 189 | .executeTakeFirst(); 190 | return user; 191 | }), 192 | 193 | createUser: protectedProcedure 194 | .input(z.object({ clerkUserId: z.string() })) 195 | .mutation(async ({ input, ctx }) => { 196 | const user = await db 197 | .insertInto('users') 198 | .values({ clerkUserId: input.clerkUserId }) 199 | .returningAll() 200 | .executeTakeFirst(); 201 | return user; 202 | }), 203 | 204 | updateUser: protectedProcedure 205 | .input( 206 | z.object({ 207 | id: z.number(), 208 | clerkUserId: z.string(), 209 | }), 210 | ) 211 | .mutation(async ({ input }) => { 212 | const user = await db 213 | .updateTable('users') 214 | .set({ clerkUserId: input.clerkUserId }) 215 | .where('id', '=', input.id) 216 | .returningAll() 217 | .executeTakeFirst(); 218 | return user; 219 | }), 220 | }); 221 | ``` 222 | 223 | ### 4. Frontend Development 224 | 225 | **Location**: `src/app/` and `src/components/` 226 | **Patterns**: 227 | 228 | - Use functional React components (not arrow functions) 229 | - tRPC hooks for type-safe API calls: `api.endpointName.useQuery()` 230 | - Clerk components for authentication 231 | - Tailwind CSS for styling 232 | 233 | **Example Component**: 234 | 235 | ```typescript 236 | function MyComponent() { 237 | const query = api.getProtected.useQuery(); 238 | 239 | return ( 240 |
241 | {query.data &&

{query.data.message}

} 242 |
243 | ); 244 | } 245 | ``` 246 | 247 | ### 5. Authentication 248 | 249 | **Provider**: Clerk 250 | **Integration**: 251 | 252 | - Wrapped in `ClerkProvider` in layout 253 | - `SignedIn`/`SignedOut` components for conditional rendering 254 | - `UserButton` for user management 255 | - tRPC context includes `auth` and `userId` 256 | 257 | ## Key Commands 258 | 259 | ```bash 260 | # Full setup (creates DB, applies schema, generates types) 261 | bun run setup 262 | 263 | # Development 264 | bun run dev 265 | 266 | # Database operations - Two-step workflow 267 | bun run db:check # Preview changes (dry run) 268 | bun run db:push # Apply schema to database 269 | bun run db:generate # Generate types from database 270 | bun run db:setup # Apply schema + generate types (one step) 271 | 272 | # Database planning (advanced workflow) 273 | bun run db:plan # Create migration plan file 274 | bun run db:plan:stage # Create staging migration plan 275 | bun run db:plan:prod # Create production migration plan 276 | 277 | # Environment-specific database operations 278 | bun run db:check:stage # Preview staging changes 279 | bun run db:check:prod # Preview production changes 280 | bun run db:push:stage # Apply to staging database 281 | bun run db:push:prod # Apply to production database 282 | ``` 283 | 284 | ## Environment Configuration 285 | 286 | **Required Environment Variables**: 287 | 288 | - `DATABASE_URL` - PostgreSQL connection string 289 | - `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY` - Clerk public key 290 | - `CLERK_SECRET_KEY` - Clerk secret key 291 | 292 | **Optional**: 293 | 294 | - `STAGING_DATABASE_URL` - Staging database 295 | - `PRODUCTION_DATABASE_URL` - Production database 296 | 297 | ## File Structure Patterns 298 | 299 | ### Database Layer 300 | 301 | - `db/schema.sql` - Declarative schema definition 302 | - `src/lib/db.ts` - Database connection setup 303 | - `src/lib/db-types.ts` - Auto-generated TypeScript types 304 | 305 | ### API Layer 306 | 307 | - `src/server/trpc.ts` - tRPC server configuration 308 | - `src/server/routers/_app.ts` - Main API router 309 | - `src/app/api/trpc/[trpc]/route.ts` - tRPC API endpoint 310 | 311 | ### Frontend Layer 312 | 313 | - `src/app/layout.tsx` - Root layout with providers 314 | - `src/app/page.tsx` - Home page 315 | - `src/components/` - Reusable components 316 | - `src/components/providers.tsx` - tRPC and React Query providers 317 | 318 | ## Common Development Scenarios 319 | 320 | ### Adding a New Database Table 321 | 322 | 1. Add table definition to `db/schema.sql` 323 | 2. **Check changes**: Run `bun run db:check` to preview what will be applied 324 | 3. **Apply changes**: Run `bun run db:push` to apply the schema changes 325 | 4. **Generate types**: Run `bun run db:generate` to regenerate TypeScript types 326 | 5. Or use `bun run db:setup` to apply changes and regenerate types in one step 327 | 6. Use generated types in `src/lib/db-types.ts` 328 | 7. Create tRPC procedures in `src/server/routers/_app.ts` 329 | 8. Use in frontend components with `api.tableName.useQuery()` 330 | 331 | ### Adding Authentication to an Endpoint 332 | 333 | 1. Use `protectedProcedure` instead of `publicProcedure` 334 | 2. Access `ctx.userId` for user identification 335 | 3. Frontend will automatically handle auth state 336 | 337 | ### Creating New Pages 338 | 339 | 1. Add files to `src/app/` following Next.js App Router conventions 340 | 2. Use `'use client'` directive for client components 341 | 3. Import and use tRPC hooks for data fetching 342 | 4. Use Clerk components for auth-aware rendering 343 | 344 | ### Styling Components 345 | 346 | 1. Use Tailwind CSS classes 347 | 2. Leverage Shadcn/UI components from `src/components/ui/` 348 | 3. Follow the design system with `bg-background`, `text-foreground`, etc. 349 | 350 | ## Best Practices 351 | 352 | 1. **Type Safety**: Always use the generated types from Kysely 353 | 2. **Database Changes**: Only modify `db/schema.sql`, never edit generated types 354 | 3. **API Design**: Use Zod for input validation, return structured responses 355 | 4. **Component Design**: Use functional components, prefer composition over inheritance 356 | 5. **Error Handling**: Leverage tRPC's built-in error handling 357 | 6. **Authentication**: Use Clerk's components and tRPC's protected procedures 358 | 7. **Database Queries**: Use Kysely's type-safe query builder, avoid raw SQL when possible 359 | 360 | ## AI Development Workflow 361 | 362 | When working with this codebase as an AI assistant: 363 | 364 | 1. **Database Changes**: 365 | - Modify `db/schema.sql` 366 | - Run `bun run db:check` to preview changes 367 | - Run `bun run db:push` to apply changes 368 | - Run `bun run db:generate` to regenerate types 369 | - Or use `bun run db:setup` for the complete workflow 370 | 2. **API Development**: Add procedures to `src/server/routers/_app.ts` 371 | 3. **Frontend Development**: Create components in `src/components/` or pages in `src/app/` 372 | 4. **Type Safety**: Leverage the existing type system - don't create manual types 373 | 5. **Authentication**: Use existing Clerk integration patterns 374 | 6. **Styling**: Use Tailwind CSS and existing Shadcn/UI components 375 | 7. **Database Operations**: Use Kysely query builder with generated types for type-safe database operations 376 | 377 | This template is designed for rapid iteration and AI-assisted development with minimal configuration and maximum type safety. 378 | --------------------------------------------------------------------------------