├── .npmrc ├── .prettierignore ├── .eslintignore ├── public └── favicon.ico ├── postcss.config.js ├── src ├── trpc │ ├── @trpc │ │ └── next-layout │ │ │ ├── index.ts │ │ │ ├── create-hydrate-client.tsx │ │ │ ├── local-storage.ts │ │ │ └── create-trpc-next-layout.ts │ └── client │ │ ├── hydrate-client.tsx │ │ └── trpc-client.tsx ├── shared │ ├── utils.ts │ ├── server-rsc │ │ ├── trpc.ts │ │ └── get-user.tsx │ └── hydration.ts ├── db │ ├── drizzle-db.ts │ └── schema.ts ├── components │ ├── main-nav │ │ ├── main-nav.tsx │ │ └── main-nav-inner.tsx │ ├── theme-provider.tsx │ ├── ui │ │ ├── lib │ │ │ └── utils.ts │ │ ├── label.tsx │ │ ├── input.tsx │ │ ├── separator.tsx │ │ ├── popover.tsx │ │ ├── hover-card.tsx │ │ ├── tooltip.tsx │ │ ├── avatar.tsx │ │ ├── scroll-area.tsx │ │ ├── button.tsx │ │ ├── dialog.tsx │ │ ├── select.tsx │ │ ├── navigation-menu.tsx │ │ └── dropdown-menu.tsx │ ├── sign-in-options.tsx │ ├── main-dropdown-menu.tsx │ ├── mobile-nav.tsx │ ├── icons.tsx │ └── posts-table.tsx ├── config │ ├── site.ts │ └── docs.ts ├── server │ ├── routers │ │ ├── _app.ts │ │ └── example.ts │ ├── auth.ts │ ├── env.js │ ├── context.ts │ └── trpc.ts ├── app │ ├── globals.css │ ├── posts │ │ └── create │ │ │ ├── page.tsx │ │ │ └── create-post-form.tsx │ ├── page.tsx │ ├── api │ │ ├── auth │ │ │ └── [...nextauth] │ │ │ │ └── route.ts │ │ └── trpc │ │ │ └── [trpc] │ │ │ └── route.ts │ ├── profile │ │ └── page.tsx │ ├── post │ │ └── [slug] │ │ │ └── page.tsx │ └── layout.tsx └── auth │ ├── options.ts │ ├── server │ └── index.ts │ ├── client │ └── index.ts │ └── adapters │ └── drizzle-orm.ts ├── .vscode ├── extensions.json ├── cspell.json └── settings.json ├── next-env.d.ts ├── drizzle.config.ts ├── prettier.config.js ├── next.config.js ├── .gitignore ├── patches ├── @tanstack__react-query@4.29.1.patch └── @noble__hashes@1.3.0.patch ├── tsconfig.json ├── .eslintrc.cjs ├── .env.example ├── tailwind.config.cjs ├── package.json └── README.md /.npmrc: -------------------------------------------------------------------------------- 1 | strict-peer-dependencies=false -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | /.vscode/cspell.json 2 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | /next.config.js 2 | /src/server/env.js 3 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mattddean/t3-app-router-edge-drizzle/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /src/trpc/@trpc/next-layout/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./create-hydrate-client"; 2 | export * from "./create-trpc-next-layout"; 3 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["esbenp.prettier-vscode", "dbaeumer.vscode-eslint", "bradlc.vscode-tailwindcss"] 3 | } 4 | -------------------------------------------------------------------------------- /next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/basic-features/typescript for more information. 6 | -------------------------------------------------------------------------------- /drizzle.config.ts: -------------------------------------------------------------------------------- 1 | import "dotenv/config"; 2 | import type { Config } from "drizzle-kit"; 3 | 4 | const config: Config = { 5 | schema: "./src/db/schema.ts", 6 | connectionString: process.env.DB_URL, 7 | }; 8 | 9 | export default config; 10 | -------------------------------------------------------------------------------- /prettier.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import("prettier").Config} */ 2 | module.exports = { 3 | printWidth: 120, 4 | trailingComma: "all", 5 | plugins: [require.resolve("prettier-plugin-tailwindcss")], 6 | tailwindConfig: "./tailwind.config.cjs", 7 | }; 8 | -------------------------------------------------------------------------------- /src/trpc/client/hydrate-client.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import superjson from "superjson"; 4 | import { createHydrateClient } from "~/trpc/@trpc/next-layout"; 5 | 6 | export const HydrateClient = createHydrateClient({ 7 | transformer: superjson, 8 | }); 9 | -------------------------------------------------------------------------------- /src/shared/utils.ts: -------------------------------------------------------------------------------- 1 | import { type inferRouterInputs, type inferRouterOutputs } from "@trpc/server"; 2 | import type { AppRouter } from "~/server/routers/_app"; 3 | 4 | export type Inputs = inferRouterInputs; 5 | export type Outputs = inferRouterOutputs; 6 | -------------------------------------------------------------------------------- /src/db/drizzle-db.ts: -------------------------------------------------------------------------------- 1 | import { connect } from "@planetscale/database"; 2 | import { drizzle } from "drizzle-orm/planetscale-serverless"; 3 | 4 | // Create the connection. 5 | const connection = connect({ 6 | host: process.env.DB_HOST, 7 | username: process.env.DB_USERNAME, 8 | password: process.env.DB_PASSWORD, 9 | }); 10 | 11 | export const db = drizzle(connection); 12 | -------------------------------------------------------------------------------- /src/components/main-nav/main-nav.tsx: -------------------------------------------------------------------------------- 1 | import type { FC } from "react"; 2 | import { rsc } from "../../shared/server-rsc/trpc"; 3 | import { MainNavInner } from "./main-nav-inner"; 4 | 5 | /* @ts-expect-error Async Server Component */ 6 | export const MainNav: FC = async () => { 7 | const user = await rsc.whoami.fetch(); 8 | 9 | return ; 10 | }; 11 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | /** @type {import('next').NextConfig} */ 4 | const nextConfig = { 5 | reactStrictMode: true, 6 | experimental: { 7 | appDir: true, 8 | typedRoutes: true, 9 | }, 10 | typescript: { 11 | // TODO: turn this off once we get things more stable 12 | ignoreBuildErrors: true, 13 | }, 14 | }; 15 | 16 | module.exports = nextConfig; 17 | -------------------------------------------------------------------------------- /src/config/site.ts: -------------------------------------------------------------------------------- 1 | interface SiteConfig { 2 | name: string; 3 | description: string; 4 | links: { 5 | twitter: string; 6 | github: string; 7 | }; 8 | } 9 | 10 | export const siteConfig: SiteConfig = { 11 | name: "T3 App Router (Edge)", 12 | description: "Example app.", 13 | links: { 14 | twitter: "https://twitter.com/matt_d_dean", 15 | github: "https://github.com/mattddean", 16 | }, 17 | }; 18 | -------------------------------------------------------------------------------- /src/server/routers/_app.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This file contains the root router of your tRPC-backend 3 | */ 4 | import { publicProcedure, router } from "../trpc"; 5 | import { exampleRouter } from "./example"; 6 | 7 | export const appRouter = router({ 8 | example: exampleRouter, 9 | whoami: publicProcedure.query(({ ctx }) => { 10 | return ctx.user ?? null; 11 | }), 12 | }); 13 | 14 | export type AppRouter = typeof appRouter; 15 | -------------------------------------------------------------------------------- /src/components/theme-provider.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { ThemeProvider as NextThemesProvider } from "next-themes"; 4 | import { type ThemeProviderProps } from "next-themes/dist/types"; 5 | 6 | /** Wrap next-theme's provider in a client component so that we can use context */ 7 | export function ThemeProvider({ children, ...props }: ThemeProviderProps) { 8 | return {children}; 9 | } 10 | -------------------------------------------------------------------------------- /src/components/ui/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 | 8 | export function formatDate(input: string | number): string { 9 | const date = new Date(input); 10 | return date.toLocaleDateString("en-US", { 11 | month: "long", 12 | day: "numeric", 13 | year: "numeric", 14 | }); 15 | } 16 | -------------------------------------------------------------------------------- /src/server/auth.ts: -------------------------------------------------------------------------------- 1 | export type { DefaultSession, Session } from "@auth/core/types"; 2 | 3 | /** 4 | * Module augmentation for `@auth/core/types` types 5 | * Allows us to add custom properties to the `session` object 6 | * and keep type safety 7 | * @see https://next-auth.js.org/getting-started/typescript#module-augmentation 8 | */ 9 | 10 | declare module "@auth/core/types" { 11 | interface Session extends DefaultSession { 12 | user: { 13 | id: string; 14 | } & DefaultSession["user"]; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | .pnpm-debug.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | 37 | .env 38 | 39 | /drizzle.config.json 40 | -------------------------------------------------------------------------------- /src/app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | html, 6 | body { 7 | padding: 0; 8 | margin: 0; 9 | font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen, 10 | Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif; 11 | } 12 | 13 | a { 14 | color: inherit; 15 | text-decoration: none; 16 | } 17 | 18 | * { 19 | box-sizing: border-box; 20 | } 21 | 22 | @media (prefers-color-scheme: dark) { 23 | html { 24 | color-scheme: dark; 25 | } 26 | body { 27 | color: white; 28 | background: black; 29 | } 30 | } 31 | 32 | -------------------------------------------------------------------------------- /patches/@tanstack__react-query@4.29.1.patch: -------------------------------------------------------------------------------- 1 | diff --git a/build/lib/reactBatchedUpdates.mjs b/build/lib/reactBatchedUpdates.mjs 2 | index 8a5ec0f3acd8582e6d63573a9479b9cae6b40f88..48c77d58736392bc3712651c978f4f5e48697993 100644 3 | --- a/build/lib/reactBatchedUpdates.mjs 4 | +++ b/build/lib/reactBatchedUpdates.mjs 5 | @@ -1,6 +1,6 @@ 6 | -import * as ReactDOM from 'react-dom'; 7 | - 8 | -const unstable_batchedUpdates = ReactDOM.unstable_batchedUpdates; 9 | +const unstable_batchedUpdates = (callback) => { 10 | + callback() 11 | +} 12 | 13 | export { unstable_batchedUpdates }; 14 | //# sourceMappingURL=reactBatchedUpdates.mjs.map -------------------------------------------------------------------------------- /src/shared/server-rsc/trpc.ts: -------------------------------------------------------------------------------- 1 | import { cookies } from "next/headers"; 2 | import superjson from "superjson"; 3 | import { createContext } from "~/server/context"; 4 | import { appRouter } from "~/server/routers/_app"; 5 | import { createTRPCNextLayout } from "~/trpc/@trpc/next-layout"; 6 | import { createGetUser } from "./get-user"; 7 | 8 | export const rsc = createTRPCNextLayout({ 9 | router: appRouter, 10 | transformer: superjson, 11 | createContext() { 12 | return createContext({ 13 | type: "rsc", 14 | // We seem to be allowed to call cookies() here. 15 | getUser: createGetUser(cookies()), 16 | }); 17 | }, 18 | }); 19 | -------------------------------------------------------------------------------- /.vscode/cspell.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2", 3 | "ignorePaths": [], 4 | "dictionaryDefinitions": [], 5 | "dictionaries": [], 6 | "words": [ 7 | "codegen", 8 | "middlewares", 9 | "paralleldrive" 10 | ], 11 | "ignoreWords": [ 12 | "authed", 13 | "ciphertext", 14 | "clsx", 15 | "datetime", 16 | "doesn", 17 | "hasn", 18 | "lucide", 19 | "nextauth", 20 | "nextjs", 21 | "pkce", 22 | "planetscale", 23 | "shadcn", 24 | "signin", 25 | "signout", 26 | "solidauth", 27 | "solidjs", 28 | "superjson", 29 | "tailwindcss", 30 | "tanstack", 31 | "trpc", 32 | "unsub" 33 | ], 34 | "import": [] 35 | } 36 | -------------------------------------------------------------------------------- /src/components/ui/label.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as LabelPrimitive from "@radix-ui/react-label"; 4 | import * as React from "react"; 5 | import { cn } from "./lib/utils"; 6 | 7 | const Label = React.forwardRef< 8 | React.ElementRef, 9 | React.ComponentPropsWithoutRef 10 | >(({ className, ...props }, ref) => ( 11 | 19 | )); 20 | Label.displayName = LabelPrimitive.Root.displayName; 21 | 22 | export { Label }; 23 | -------------------------------------------------------------------------------- /src/config/docs.ts: -------------------------------------------------------------------------------- 1 | export interface NavItem { 2 | title: string; 3 | href?: "/posts/create"; 4 | disabled?: boolean; 5 | external?: boolean; 6 | label?: string; 7 | } 8 | 9 | export interface NavItemWithChildren extends NavItem { 10 | items: NavItemWithChildren[]; 11 | } 12 | 13 | export type MainNavItem = NavItem; 14 | 15 | export type SidebarNavItem = NavItemWithChildren; 16 | 17 | interface DocsConfig { 18 | sidebarNav: SidebarNavItem[]; 19 | } 20 | 21 | export const docsConfig: DocsConfig = { 22 | sidebarNav: [ 23 | { 24 | title: "Posts", 25 | items: [ 26 | { 27 | title: "Create Post", 28 | href: "/posts/create", 29 | items: [], 30 | }, 31 | ], 32 | }, 33 | ], 34 | }; 35 | -------------------------------------------------------------------------------- /src/server/env.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | /** 3 | * This file is included in `/next.config.js` which ensures the app isn't built with invalid env vars. 4 | * It has to be a `.js`-file to be imported there. 5 | */ 6 | /* eslint-disable @typescript-eslint/no-var-requires */ 7 | const { z } = require('zod'); 8 | 9 | /*eslint sort-keys: "error"*/ 10 | const envSchema = z.object({ 11 | // DATABASE_URL: z.string().url(), 12 | NODE_ENV: z.enum(['development', 'test', 'production']), 13 | }); 14 | 15 | const env = envSchema.safeParse(process.env); 16 | 17 | if (!env.success) { 18 | console.error( 19 | '❌ Invalid environment variables:', 20 | JSON.stringify(env.error.format(), null, 4), 21 | ); 22 | process.exit(1); 23 | } 24 | module.exports.env = env.data; 25 | -------------------------------------------------------------------------------- /src/components/sign-in-options.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { type FC } from "react"; 4 | import { signIn } from "~/auth/client"; 5 | import { GithubIcon, GoogleIcon } from "./icons"; 6 | import { Button } from "./ui/button"; 7 | 8 | const SignInButtons: FC = () => { 9 | return ( 10 |
11 | 15 | 19 |
20 | ); 21 | }; 22 | 23 | export default SignInButtons; 24 | -------------------------------------------------------------------------------- /src/shared/hydration.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Helper to properly serialize and deserialize data from RSC to client and keeping the types 3 | */ 4 | import { useMemo } from "react"; 5 | import superjson from "superjson"; 6 | import type { SuperJSONResult } from "superjson/dist/types"; 7 | 8 | const symbol = Symbol("__RSC_DATA__"); 9 | 10 | export type SerializedResult = SuperJSONResult & { [symbol]: T }; 11 | 12 | export function serialize(obj: T): SerializedResult { 13 | return superjson.serialize(obj) as unknown as SerializedResult; 14 | } 15 | 16 | export function deserialize(obj: SerializedResult): T { 17 | return superjson.deserialize(obj); 18 | } 19 | 20 | export function useDeserialized(obj: SerializedResult): T { 21 | return useMemo(() => deserialize(obj), [obj]); 22 | } 23 | -------------------------------------------------------------------------------- /src/components/ui/input.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | 5 | import { cn } from "./lib/utils"; 6 | 7 | export type InputProps = React.InputHTMLAttributes; 8 | 9 | const Input = React.forwardRef(({ className, ...props }, ref) => { 10 | return ( 11 | 19 | ); 20 | }); 21 | Input.displayName = "Input"; 22 | 23 | export { Input }; 24 | -------------------------------------------------------------------------------- /src/components/ui/separator.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as SeparatorPrimitive from "@radix-ui/react-separator"; 4 | import * as React from "react"; 5 | import { cn } from "./lib/utils"; 6 | 7 | const Separator = React.forwardRef< 8 | React.ElementRef, 9 | React.ComponentPropsWithoutRef 10 | >(({ className, orientation = "horizontal", decorative = true, ...props }, ref) => ( 11 | 22 | )); 23 | Separator.displayName = SeparatorPrimitive.Root.displayName; 24 | 25 | export { Separator }; 26 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2017", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "checkJs": true, 7 | "skipLibCheck": true, 8 | "strict": true, 9 | "forceConsistentCasingInFileNames": true, 10 | "noEmit": true, 11 | "esModuleInterop": true, 12 | "module": "esnext", 13 | "moduleResolution": "node", 14 | "resolveJsonModule": true, 15 | "isolatedModules": true, 16 | "jsx": "preserve", 17 | "incremental": true, 18 | "noUncheckedIndexedAccess": true, 19 | "baseUrl": ".", 20 | "plugins": [ 21 | { 22 | "name": "next" 23 | } 24 | ], 25 | "paths": { 26 | "~/*": ["./src/*"] 27 | } 28 | }, 29 | "include": [".eslintrc.cjs", "next-env.d.ts", "**/*.ts", "**/*.tsx", "**/*.cjs", "**/*.mjs", ".next/types/**/*.ts"], 30 | "exclude": ["node_modules"] 31 | } 32 | -------------------------------------------------------------------------------- /src/app/posts/create/page.tsx: -------------------------------------------------------------------------------- 1 | import type { FC } from "react"; 2 | import SignInButtons from "~/components/sign-in-options"; 3 | import { rsc } from "~/shared/server-rsc/trpc"; 4 | import CreatePostForm from "./create-post-form"; 5 | 6 | export const runtime = "edge"; 7 | export const revalidate = 0; 8 | export const metadata = { 9 | title: "Create Post", 10 | description: "Create a new post.", 11 | }; 12 | 13 | /* @ts-expect-error Async Server Component */ 14 | const CreatePost: FC = async () => { 15 | const user = await rsc.whoami.fetch(); 16 | 17 | return ( 18 | <> 19 |
20 |
21 | {!user && } 22 | 23 | {!!user ? :
You must sign in to create a post.
} 24 |
25 | 26 | ); 27 | }; 28 | 29 | export default CreatePost; 30 | -------------------------------------------------------------------------------- /src/trpc/@trpc/next-layout/create-hydrate-client.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Hydrate, type DehydratedState } from "@tanstack/react-query"; 4 | import { type DataTransformer } from "@trpc/server"; 5 | import { useMemo } from "react"; 6 | 7 | export function createHydrateClient(opts: { transformer?: DataTransformer }) { 8 | return function HydrateClient(props: { children: React.ReactNode; state: DehydratedState }) { 9 | const { state, children } = props; 10 | 11 | // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment 12 | const transformedState: DehydratedState = useMemo(() => { 13 | if (opts.transformer) { 14 | // eslint-disable-next-line @typescript-eslint/no-unsafe-return 15 | return opts.transformer.deserialize(state); 16 | } 17 | return state; 18 | }, [state]); 19 | return {children}; 20 | }; 21 | } 22 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "javascript.validate.enable": false, 3 | "typescript.validate.enable": true, 4 | "typescript.preferences.importModuleSpecifier": "relative", 5 | "typescript.updateImportsOnFileMove.enabled": "always", 6 | "typescript.tsdk": "./node_modules/typescript/lib", 7 | "editor.defaultFormatter": "esbenp.prettier-vscode", 8 | "typescript.suggest.completeFunctionCalls": true, 9 | "eslint.lintTask.enable": true, 10 | "typescript.surveys.enabled": false, 11 | "npm.autoDetect": "on", 12 | "git.inputValidationLength": 1000, 13 | "git.inputValidationSubjectLength": 100, 14 | "eslint.onIgnoredFiles": "off", 15 | "editor.tabSize": 2, 16 | "editor.detectIndentation": false, 17 | "editor.formatOnSave": true, 18 | "editor.codeActionsOnSave": { 19 | "source.organizeImports": true 20 | }, 21 | "files.watcherExclude": { 22 | ".git": true, 23 | "node_modules": true 24 | }, 25 | "typescript.enablePromptUseWorkspaceTsdk": true 26 | } 27 | -------------------------------------------------------------------------------- /src/trpc/@trpc/next-layout/local-storage.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This file makes sure that we can get a storage that is unique to the current request context 3 | */ 4 | 5 | /* eslint-disable @typescript-eslint/no-var-requires */ 6 | /* eslint-disable @typescript-eslint/ban-types */ 7 | 8 | import type { AsyncLocalStorage } from "async_hooks"; 9 | 10 | // https://github.com/vercel/next.js/blob/canary/packages/next/client/components/request-async-storage.ts 11 | // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-explicit-any 12 | export const asyncStorage: AsyncLocalStorage | {} = 13 | // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access 14 | require("next/dist/client/components/request-async-storage").requestAsyncStorage; 15 | 16 | function throwError(msg: string) { 17 | throw new Error(msg); 18 | } 19 | export function getRequestStorage(): T { 20 | if ("getStore" in asyncStorage) { 21 | // eslint-disable-next-line @typescript-eslint/no-unsafe-return 22 | return asyncStorage.getStore() ?? throwError("Couldn't get async storage"); 23 | } 24 | 25 | return asyncStorage as T; 26 | } 27 | -------------------------------------------------------------------------------- /src/components/ui/popover.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as PopoverPrimitive from "@radix-ui/react-popover"; 4 | import * as React from "react"; 5 | import { cn } from "./lib/utils"; 6 | 7 | const Popover = PopoverPrimitive.Root; 8 | 9 | const PopoverTrigger = PopoverPrimitive.Trigger; 10 | 11 | const PopoverContent = React.forwardRef< 12 | React.ElementRef, 13 | React.ComponentPropsWithoutRef 14 | >(({ className, align = "center", sideOffset = 4, ...props }, ref) => ( 15 | 16 | 26 | 27 | )); 28 | PopoverContent.displayName = PopoverPrimitive.Content.displayName; 29 | 30 | export { Popover, PopoverTrigger, PopoverContent }; 31 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line @typescript-eslint/no-var-requires 2 | const path = require("path"); 3 | 4 | /** @type {import("eslint").Linter.Config} */ 5 | const config = { 6 | overrides: [ 7 | { 8 | extends: ["plugin:@typescript-eslint/recommended-requiring-type-checking"], 9 | files: ["*.ts", "*.tsx"], 10 | parserOptions: { 11 | project: path.join(__dirname, "tsconfig.json"), 12 | }, 13 | }, 14 | ], 15 | parser: "@typescript-eslint/parser", 16 | parserOptions: { 17 | project: path.join(__dirname, "tsconfig.json"), 18 | }, 19 | plugins: ["@typescript-eslint"], 20 | extends: ["next/core-web-vitals", "plugin:@typescript-eslint/recommended"], 21 | rules: { 22 | "@typescript-eslint/consistent-type-imports": [ 23 | "warn", 24 | { 25 | prefer: "type-imports", 26 | fixStyle: "inline-type-imports", 27 | }, 28 | ], 29 | "@typescript-eslint/no-unused-vars": [ 30 | "warn", 31 | { 32 | vars: "all", 33 | args: "after-used", 34 | ignoreRestSiblings: false, 35 | argsIgnorePattern: "^_", 36 | varsIgnorePattern: "^_", 37 | }, 38 | ], 39 | }, 40 | }; 41 | 42 | module.exports = config; 43 | -------------------------------------------------------------------------------- /src/components/ui/hover-card.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as HoverCardPrimitive from "@radix-ui/react-hover-card"; 4 | import * as React from "react"; 5 | import { cn } from "./lib/utils"; 6 | 7 | const DEFAULT_DELAY = 80; 8 | 9 | const HoverCard: React.FC = (props) => { 10 | return ; 11 | }; 12 | 13 | const HoverCardTrigger = HoverCardPrimitive.Trigger; 14 | 15 | const HoverCardContent = React.forwardRef< 16 | React.ElementRef, 17 | React.ComponentPropsWithoutRef 18 | >(({ className, align = "center", sideOffset = 4, ...props }, ref) => ( 19 | 29 | )); 30 | HoverCardContent.displayName = HoverCardPrimitive.Content.displayName; 31 | 32 | export { HoverCard, HoverCardTrigger, HoverCardContent }; 33 | -------------------------------------------------------------------------------- /src/auth/options.ts: -------------------------------------------------------------------------------- 1 | import GithubProvider from "@auth/core/providers/github"; 2 | import GoogleProvider from "@auth/core/providers/google"; 3 | import { db } from "~/db/drizzle-db"; 4 | import { createDrizzleAdapter } from "./adapters/drizzle-orm"; 5 | import { type SolidAuthConfig } from "./server"; 6 | 7 | export const authConfig: SolidAuthConfig = { 8 | // Configure one or more authentication providers 9 | adapter: createDrizzleAdapter(db), 10 | providers: [ 11 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 12 | // @ts-ignore growing pains 13 | GithubProvider({ 14 | clientId: process.env.GITHUB_ID as string, 15 | clientSecret: process.env.GITHUB_SECRET as string, 16 | }), 17 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 18 | // @ts-ignore growing pains 19 | GoogleProvider({ 20 | clientId: process.env.GOOGLE_CLIENT_ID as string, 21 | clientSecret: process.env.GOOGLE_CLIENT_SECRET as string, 22 | }), 23 | ], 24 | callbacks: { 25 | session({ session, user }) { 26 | if (session.user) { 27 | session.user.id = user.id; 28 | } 29 | return session; 30 | }, 31 | }, 32 | session: { 33 | strategy: "database", 34 | }, 35 | }; 36 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # Since .env is gitignored, you can use .env.example to build a new `.env` file when you clone the repo. 2 | # Keep this file up-to-date when you add new variables to \`.env\`. 3 | 4 | # This file will be committed to version control, so make sure not to have any secrets in it. 5 | # If you are cloning this repo, create a copy of this file named `.env` and populate it with your secrets. 6 | 7 | # You must use a PlanetScale database. This repo depends on the @planetscale/database driver. 8 | # But you can swap out `drizzle-orm/planetscale-serverless` with a different package to use a different database provider; just make sure it supports the edge runtime. 9 | DB_HOST= 10 | DB_USERNAME= 11 | DB_PASSWORD= 12 | DB_URL='mysql://username:password@region.connect.psdb.cloud:3306/database?ssl={"rejectUnauthorized":true}' 13 | 14 | # @see https://next-auth.js.org/configuration/options#nextauth_url 15 | NEXTAUTH_URL='http://localhost:3000' 16 | 17 | # You can generate the secret via 'openssl rand -base64 32' on Unix 18 | # @see https://next-auth.js.org/configuration/options#secret 19 | AUTH_SECRET= 20 | 21 | # @see https://next-auth.js.org/providers/discord 22 | # DISCORD_CLIENT_ID= 23 | # DISCORD_CLIENT_SECRET= 24 | 25 | GITHUB_ID= 26 | GITHUB_SECRET= 27 | GOOGLE_CLIENT_ID= 28 | GOOGLE_CLIENT_SECRET= 29 | 30 | NODE_VERSION=14 31 | -------------------------------------------------------------------------------- /src/components/ui/tooltip.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as TooltipPrimitive from "@radix-ui/react-tooltip"; 4 | import * as React from "react"; 5 | import { cn } from "./lib/utils"; 6 | 7 | const TooltipProvider = TooltipPrimitive.Provider; 8 | 9 | const Tooltip = ({ ...props }) => ; 10 | Tooltip.displayName = TooltipPrimitive.Tooltip.displayName; 11 | 12 | const TooltipTrigger = TooltipPrimitive.Trigger; 13 | 14 | const TooltipContent = React.forwardRef< 15 | React.ElementRef, 16 | React.ComponentPropsWithoutRef 17 | >(({ className, sideOffset = 4, ...props }, ref) => ( 18 | 27 | )); 28 | TooltipContent.displayName = TooltipPrimitive.Content.displayName; 29 | 30 | export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }; 31 | -------------------------------------------------------------------------------- /tailwind.config.cjs: -------------------------------------------------------------------------------- 1 | const { fontFamily } = require("tailwindcss/defaultTheme"); 2 | 3 | /** @type {import("tailwindcss").Config} */ 4 | module.exports = { 5 | darkMode: ["class"], 6 | content: ["./**/*.{ts,tsx}"], 7 | theme: { 8 | container: { 9 | center: true, 10 | padding: "1.5rem", 11 | screens: { 12 | "2xl": "1440px", 13 | }, 14 | }, 15 | extend: { 16 | fontFamily: { 17 | sans: ["var(--font-sans)", ...fontFamily.sans], 18 | }, 19 | keyframes: { 20 | "accordion-down": { 21 | from: { height: 0 }, 22 | to: { height: "var(--radix-accordion-content-height)" }, 23 | }, 24 | "accordion-up": { 25 | from: { height: "var(--radix-accordion-content-height)" }, 26 | to: { height: 0 }, 27 | }, 28 | // https://play.tailwindcss.com/YooA6NXDHi?layout=preview 29 | border: { 30 | "0%, 100%": { backgroundPosition: "0% 50%" }, 31 | "50%": { backgroundPosition: "100% 50%" }, 32 | }, 33 | }, 34 | animation: { 35 | "accordion-down": "accordion-down 0.2s ease-out", 36 | "accordion-up": "accordion-up 0.2s ease-out", 37 | // https://play.tailwindcss.com/YooA6NXDHi?layout=preview 38 | border: "border 4s ease infinite", 39 | }, 40 | }, 41 | }, 42 | plugins: [require("tailwindcss-animate")], 43 | }; 44 | -------------------------------------------------------------------------------- /src/server/context.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-unused-vars */ 2 | import type { inferAsyncReturnType } from "@trpc/server"; 3 | import type { FetchCreateContextFnOptions } from "@trpc/server/adapters/fetch"; 4 | import type { GetUser, User } from "~/shared/server-rsc/get-user"; 5 | 6 | /** 7 | * Inner function for `createContext` where we create the context. 8 | * This is useful for testing when we don't want to mock Next.js' request/response 9 | */ 10 | export function createContextInner(opts: { user: User | null; rsc: boolean }) { 11 | return { 12 | user: opts.user, 13 | }; 14 | } 15 | 16 | /** 17 | * Creates context for an incoming request 18 | * @link https://trpc.io/docs/context 19 | */ 20 | export async function createContext( 21 | // HACKs because we can't import `next/cookies` in `/api`-routes 22 | opts: 23 | | { 24 | type: "rsc"; 25 | getUser: GetUser; 26 | } 27 | | (FetchCreateContextFnOptions & { type: "api"; getUser: GetUser }), 28 | ) { 29 | // for API-response caching see https://trpc.io/docs/caching 30 | 31 | // RSC 32 | if (opts.type === "rsc") { 33 | return { 34 | type: opts.type, 35 | user: await opts.getUser(), 36 | }; 37 | } 38 | 39 | // not RSC 40 | return { 41 | type: opts.type, 42 | user: await opts.getUser(), 43 | }; 44 | } 45 | 46 | export type Context = inferAsyncReturnType; 47 | -------------------------------------------------------------------------------- /src/app/page.tsx: -------------------------------------------------------------------------------- 1 | import type { FC } from "react"; 2 | import { PostsTable } from "~/components/posts-table"; 3 | import SignInButtons from "~/components/sign-in-options"; 4 | import { rsc } from "~/shared/server-rsc/trpc"; 5 | import { HydrateClient } from "~/trpc/client/hydrate-client"; 6 | 7 | export const runtime = "edge"; 8 | export const revalidate = 0; 9 | export const metadata = { 10 | title: "Home", 11 | description: "Home", 12 | }; 13 | 14 | /* @ts-expect-error Async Server Component */ 15 | const Home: FC = async () => { 16 | const pageSizes: [number, number, number, number] = [5, 10, 25, 50]; 17 | const initialPageSize = pageSizes[0]; 18 | 19 | const [user] = await Promise.all([ 20 | rsc.whoami.fetch(), 21 | // Fetch the first page of data that PostsTable will look for so that it 22 | // can be dehydrated, passed to the client, and instantly retrieved. 23 | rsc.example.getInfinitePosts.fetchInfinite({ limit: initialPageSize }), 24 | ]); 25 | 26 | const dehydratedState = await rsc.dehydrate(); 27 | return ( 28 | <> 29 |
30 |
31 | {!user && } 32 | 33 | {/* Provide dehydrated state to client components. */} 34 | 35 | 36 | 37 |
38 | 39 | ); 40 | }; 41 | 42 | export default Home; 43 | -------------------------------------------------------------------------------- /src/app/api/auth/[...nextauth]/route.ts: -------------------------------------------------------------------------------- 1 | import { type NextRequest } from "next/server"; 2 | import { authConfig } from "~/auth/options"; 3 | import { SolidAuthHandler } from "~/auth/server"; 4 | 5 | export const runtime = "edge"; 6 | 7 | async function handler(request: NextRequest) { 8 | const { prefix = "/api/auth", ...authOptions } = authConfig; 9 | 10 | authOptions.secret ??= process.env.AUTH_SECRET; 11 | authOptions.trustHost ??= !!( 12 | process.env.AUTH_TRUST_HOST ?? 13 | process.env.VERCEL ?? 14 | process.env.NODE_ENV !== "production" 15 | ); 16 | 17 | // Create a new request so that we can ensure the next headers are accessed in this file. 18 | // If we pass the request we get from next to SolidAuthHandler, it will access the headers 19 | // in a way that next.js does not like and we'll end up with a requestAsyncStorage error 20 | // https://github.com/vercel/next.js/issues/46356 21 | const req = new Request(request.url, { 22 | headers: request.headers, 23 | cache: request.cache, 24 | credentials: request.credentials, 25 | integrity: request.integrity, 26 | keepalive: request.keepalive, 27 | method: request.method, 28 | mode: request.mode, 29 | redirect: request.redirect, 30 | referrer: request.referrer, 31 | referrerPolicy: request.referrerPolicy, 32 | signal: request.signal, 33 | body: request.body, 34 | }); 35 | 36 | const response = await SolidAuthHandler(req, prefix, authOptions); 37 | return response; 38 | } 39 | 40 | export { handler as GET, handler as POST }; 41 | -------------------------------------------------------------------------------- /src/shared/server-rsc/get-user.tsx: -------------------------------------------------------------------------------- 1 | import { eq } from "drizzle-orm/expressions"; 2 | import type { RequestCookies } from "next/dist/compiled/@edge-runtime/cookies"; 3 | import type { ReadonlyRequestCookies } from "next/dist/server/web/spec-extension/adapters/request-cookies"; 4 | import { db } from "~/db/drizzle-db"; 5 | import { sessions, users } from "../../db/schema"; 6 | 7 | export interface User { 8 | id: string; 9 | email: string; 10 | name: string | undefined; 11 | } 12 | 13 | export type GetUser = () => Promise; 14 | 15 | export function createGetUser(cookies: RequestCookies | ReadonlyRequestCookies) { 16 | return async () => { 17 | const newCookies = cookies.getAll().reduce((cookiesObj, cookie) => { 18 | cookiesObj[cookie.name] = cookie.value; 19 | return cookiesObj; 20 | }, {} as Record); 21 | 22 | const sessionToken = newCookies["next-auth.session-token"] ?? newCookies["__Secure-next-auth.session-token"]; 23 | if (!sessionToken) return null; 24 | 25 | const rows = await db 26 | .select({ user_id: users.id, user_name: users.name, user_email: users.email }) 27 | .from(sessions) 28 | .innerJoin(users, eq(users.id, sessions.userId)) 29 | .where(eq(sessions.sessionToken, sessionToken)) 30 | .limit(1); 31 | const session = rows[0]; 32 | if (!session) return null; 33 | 34 | const user: User = { 35 | id: session.user_id, 36 | name: session.user_name ?? undefined, 37 | email: session.user_email, 38 | }; 39 | return user; 40 | }; 41 | } 42 | -------------------------------------------------------------------------------- /src/components/ui/avatar.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as AvatarPrimitive from "@radix-ui/react-avatar"; 4 | import * as React from "react"; 5 | import { cn } from "./lib/utils"; 6 | 7 | const Avatar = React.forwardRef< 8 | React.ElementRef, 9 | React.ComponentPropsWithoutRef 10 | >(({ className, ...props }, ref) => ( 11 | 16 | )); 17 | Avatar.displayName = AvatarPrimitive.Root.displayName; 18 | 19 | const AvatarImage = React.forwardRef< 20 | React.ElementRef, 21 | React.ComponentPropsWithoutRef 22 | >(({ className, ...props }, ref) => ( 23 | 24 | )); 25 | AvatarImage.displayName = AvatarPrimitive.Image.displayName; 26 | 27 | const AvatarFallback = React.forwardRef< 28 | React.ElementRef, 29 | React.ComponentPropsWithoutRef 30 | >(({ className, ...props }, ref) => ( 31 | 39 | )); 40 | AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName; 41 | 42 | export { Avatar, AvatarImage, AvatarFallback }; 43 | -------------------------------------------------------------------------------- /src/app/api/trpc/[trpc]/route.ts: -------------------------------------------------------------------------------- 1 | import { fetchRequestHandler } from "@trpc/server/adapters/fetch"; 2 | import { cookies } from "next/headers"; 3 | import type { NextRequest } from "next/server"; 4 | import { createContext } from "~/server/context"; 5 | import { appRouter } from "~/server/routers/_app"; 6 | import { createGetUser } from "~/shared/server-rsc/get-user"; 7 | 8 | export const runtime = "edge"; 9 | 10 | const handler = (request: NextRequest) => { 11 | const req = new Request(request.url, { 12 | headers: request.headers, 13 | cache: request.cache, 14 | credentials: request.credentials, 15 | integrity: request.integrity, 16 | keepalive: request.keepalive, 17 | method: request.method, 18 | mode: request.mode, 19 | redirect: request.redirect, 20 | referrer: request.referrer, 21 | referrerPolicy: request.referrerPolicy, 22 | signal: request.signal, 23 | body: request.body, 24 | }); 25 | 26 | // We have to call cookies() in this file and then pass them in or Next.js's App Router will complain. 27 | const asyncStorageCookies = cookies(); 28 | 29 | return fetchRequestHandler({ 30 | endpoint: "/api/trpc", 31 | req, 32 | router: appRouter, 33 | createContext(opts) { 34 | return createContext({ 35 | type: "api", 36 | getUser: createGetUser(asyncStorageCookies), 37 | ...opts, 38 | }); 39 | }, 40 | onError({ error }) { 41 | if (error.code === "INTERNAL_SERVER_ERROR") { 42 | console.error("Caught TRPC error:", error); 43 | } 44 | }, 45 | }); 46 | }; 47 | 48 | export { handler as GET, handler as POST }; 49 | -------------------------------------------------------------------------------- /src/app/profile/page.tsx: -------------------------------------------------------------------------------- 1 | import type { NextPage } from "next"; 2 | import SignInButtons from "~/components/sign-in-options"; 3 | import { rsc } from "../../shared/server-rsc/trpc"; 4 | 5 | export const runtime = "edge"; 6 | export const revalidate = 0; 7 | export const metadata = { 8 | title: "Profile", 9 | description: "Your profile.", 10 | }; 11 | 12 | /* @ts-expect-error Async Server Component */ 13 | const Home: NextPage = async () => { 14 | const user = await rsc.whoami.fetch(); 15 | 16 | return ( 17 | <> 18 |
19 | 20 |
21 | {!user && } 22 | 23 | {!!user ? ( 24 |
25 |
26 |

27 | Account Information 28 |

29 |
30 |
31 |
32 |
Username
33 |
{user.name}
34 |
35 |
36 |
Email
37 |
{user.email}
38 |
39 |
40 |
41 |
42 | ) : ( 43 |
You must sign in to view your profile.
44 | )} 45 |
46 | 47 | ); 48 | }; 49 | 50 | export default Home; 51 | -------------------------------------------------------------------------------- /patches/@noble__hashes@1.3.0.patch: -------------------------------------------------------------------------------- 1 | diff --git a/cryptoNode.js b/cryptoNode.js 2 | index 939525de1e4f88203cfa6103eac0f14b611dcbb3..3038d91f0080fe5133d1baf6d3f1c9030d758f07 100644 3 | --- a/cryptoNode.js 4 | +++ b/cryptoNode.js 5 | @@ -1,6 +1,5 @@ 6 | "use strict"; 7 | Object.defineProperty(exports, "__esModule", { value: true }); 8 | exports.crypto = void 0; 9 | -const nc = require("node:crypto"); 10 | -exports.crypto = nc && typeof nc === 'object' && 'webcrypto' in nc ? nc.webcrypto : undefined; 11 | +exports.crypto = typeof globalThis === 'object' && 'crypto' in globalThis ? globalThis.crypto : undefined; 12 | //# sourceMappingURL=cryptoNode.js.map 13 | \ No newline at end of file 14 | diff --git a/esm/cryptoNode.js b/esm/cryptoNode.js 15 | index 4bac77ad987b53a5fd5384ec7dad2bd193693776..a0dac2b27013b3d612729d7526491cc2d6f5f9a0 100644 16 | --- a/esm/cryptoNode.js 17 | +++ b/esm/cryptoNode.js 18 | @@ -1,3 +1,2 @@ 19 | -import * as nc from 'node:crypto'; 20 | -export const crypto = nc && typeof nc === 'object' && 'webcrypto' in nc ? nc.webcrypto : undefined; 21 | +export const crypto = typeof globalThis === 'object' && 'crypto' in globalThis ? globalThis.crypto : undefined; 22 | //# sourceMappingURL=cryptoNode.js.map 23 | \ No newline at end of file 24 | diff --git a/src/cryptoNode.ts b/src/cryptoNode.ts 25 | index 65d0853452ba39bf9a992d8dd078d5385e7cc66c..9732af47a8db9294faf7fd77003773caca03b617 100644 26 | --- a/src/cryptoNode.ts 27 | +++ b/src/cryptoNode.ts 28 | @@ -1,3 +1,3 @@ 29 | -import * as nc from 'node:crypto'; 30 | +declare const globalThis: Record | undefined; 31 | export const crypto = 32 | - nc && typeof nc === 'object' && 'webcrypto' in nc ? (nc.webcrypto as any) : undefined; 33 | + typeof globalThis === 'object' && 'crypto' in globalThis ? globalThis.crypto : undefined; -------------------------------------------------------------------------------- /src/components/ui/scroll-area.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"; 4 | import * as React from "react"; 5 | import { cn } from "./lib/utils"; 6 | 7 | const ScrollArea = React.forwardRef< 8 | React.ElementRef, 9 | React.ComponentPropsWithoutRef 10 | >(({ className, children, ...props }, ref) => ( 11 | 12 | {children} 13 | 14 | 15 | 16 | )); 17 | ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName; 18 | 19 | const ScrollBar = React.forwardRef< 20 | React.ElementRef, 21 | React.ComponentPropsWithoutRef 22 | >(({ className, orientation = "vertical", ...props }, ref) => ( 23 | 34 | 35 | 36 | )); 37 | ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName; 38 | 39 | export { ScrollArea, ScrollBar }; 40 | -------------------------------------------------------------------------------- /src/app/post/[slug]/page.tsx: -------------------------------------------------------------------------------- 1 | import type { NextPage } from "next"; 2 | import SignInButtons from "~/components/sign-in-options"; 3 | import { rsc } from "~/shared/server-rsc/trpc"; 4 | 5 | export const runtime = "edge"; 6 | export const revalidate = 0; 7 | export async function generateMetadata({ params }: Props) { 8 | const post = await rsc.example.getPost.fetch({ slug: params.slug }); 9 | return { 10 | title: post.title, 11 | description: post.text.substring(0, 160), 12 | }; 13 | } 14 | 15 | export interface Props { 16 | params: { slug: string }; 17 | } 18 | 19 | /* @ts-expect-error Async Server Component */ 20 | const PostSlug: NextPage = async ({ params }) => { 21 | const user = await rsc.whoami.fetch(); 22 | const post = await rsc.example.getPost.fetch({ slug: params.slug }); 23 | 24 | return ( 25 | <> 26 |
27 | 28 | {!user && } 29 | 30 |
31 | {post && ( 32 | <> 33 |
34 |
35 |
36 |
{post.title}
37 |
38 |
39 |
40 |
41 |
42 |
43 |
{post.text}
44 |
45 |
46 |
47 | 48 | )} 49 |
50 | 51 | ); 52 | }; 53 | 54 | export default PostSlug; 55 | -------------------------------------------------------------------------------- /src/components/main-dropdown-menu.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import Link from "next/link"; 4 | import type { FC } from "react"; 5 | import { signOut } from "~/auth/client"; 6 | import { LogOutIcon, UserIcon } from "~/components/icons"; 7 | import { Avatar, AvatarFallback, AvatarImage } from "~/components/ui/avatar"; 8 | import { Button } from "~/components/ui/button"; 9 | import { 10 | DropdownMenu, 11 | DropdownMenuContent, 12 | DropdownMenuGroup, 13 | DropdownMenuItem, 14 | DropdownMenuLabel, 15 | DropdownMenuSeparator, 16 | DropdownMenuTrigger, 17 | } from "~/components/ui/dropdown-menu"; 18 | 19 | export interface Props { 20 | avatarFallbackText?: string; 21 | user: { email?: string | undefined; image: string | undefined }; 22 | } 23 | 24 | export const MainDropdownMenu: FC = ({ user, avatarFallbackText }) => { 25 | return ( 26 | 27 | 28 | 34 | 35 | 36 | {user.email} 37 | 38 | 39 | 40 | 41 | 42 | Profile 43 | 44 | 45 | 46 | void signOut()} className="cursor-pointer"> 47 | 48 | Log out 49 | 50 | 51 | 52 | ); 53 | }; 54 | -------------------------------------------------------------------------------- /src/components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { cva, type VariantProps } from "class-variance-authority-ts5-fix"; 4 | import * as React from "react"; 5 | import { cn } from "./lib/utils"; 6 | 7 | const buttonVariants = cva( 8 | "inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-slate-400 focus:ring-offset-2 dark:hover:bg-slate-800 dark:hover:text-slate-100 disabled:opacity-50 dark:focus:ring-slate-400 disabled:pointer-events-none dark:focus:ring-offset-slate-900 data-[state=open]:bg-slate-100 dark:data-[state=open]:bg-slate-800", 9 | { 10 | variants: { 11 | variant: { 12 | default: "bg-slate-900 text-white hover:bg-slate-700 dark:bg-slate-50 dark:text-slate-900", 13 | destructive: "bg-red-500 text-white hover:bg-red-600 dark:hover:bg-red-600", 14 | outline: "bg-transparent border border-slate-200 hover:bg-slate-100 dark:border-slate-700 dark:text-slate-100", 15 | subtle: "bg-slate-100 text-slate-900 hover:bg-slate-200 dark:bg-slate-700 dark:text-slate-100", 16 | ghost: 17 | "bg-transparent hover:bg-slate-100 dark:hover:bg-slate-800 dark:text-slate-100 dark:hover:text-slate-100 data-[state=open]:bg-transparent dark:data-[state=open]:bg-transparent", 18 | link: "bg-transparent underline-offset-4 hover:underline text-slate-900 dark:text-slate-100 hover:bg-transparent dark:hover:bg-transparent", 19 | }, 20 | size: { 21 | default: "h-10 py-2 px-4", 22 | sm: "h-9 px-2 rounded-md", 23 | lg: "h-11 px-8 rounded-md", 24 | }, 25 | }, 26 | defaultVariants: { 27 | variant: "default", 28 | size: "default", 29 | }, 30 | }, 31 | ); 32 | 33 | export interface ButtonProps 34 | extends React.ButtonHTMLAttributes, 35 | VariantProps {} 36 | 37 | const Button = React.forwardRef(({ className, variant, size, ...props }, ref) => { 38 | return 56 |
57 | 58 | ); 59 | }; 60 | 61 | export default CreatePostForm; 62 | -------------------------------------------------------------------------------- /src/auth/server/index.ts: -------------------------------------------------------------------------------- 1 | /** https://github.com/nextauthjs/next-auth/blob/04791cd57478b64d0ebdfc8fe25779e2f89e2070/packages/frameworks-solid-start/src/index.ts#L1 */ 2 | 3 | import { Auth } from "@auth/core"; 4 | import type { AuthAction, AuthConfig } from "@auth/core/types"; 5 | import { serialize, type CookieSerializeOptions } from "cookie"; 6 | import { parseString, splitCookiesString, type Cookie } from "set-cookie-parser"; 7 | 8 | export interface SolidAuthConfig extends AuthConfig { 9 | /** 10 | * Defines the base path for the auth routes. 11 | * @default '/api/auth' 12 | */ 13 | prefix?: string; 14 | } 15 | 16 | const actions: AuthAction[] = [ 17 | "providers", 18 | "session", 19 | "csrf", 20 | "signin", 21 | "signout", 22 | "callback", 23 | "verify-request", 24 | "error", 25 | ]; 26 | 27 | // currently multiple cookies are not supported, so we keep the next-auth.pkce.code_verifier cookie for now: 28 | // because it gets updated anyways 29 | // src: https://github.com/solidjs/solid-start/issues/293 30 | const getSetCookieCallback = (cook?: string | null): Cookie | undefined => { 31 | if (!cook) return; 32 | const splitCookie = splitCookiesString(cook); 33 | for (const cookName of [ 34 | "__Secure-next-auth.session-token", 35 | "next-auth.session-token", 36 | "next-auth.pkce.code_verifier", 37 | "__Secure-next-auth.pkce.code_verifier", 38 | ]) { 39 | const temp = splitCookie.find((e) => e.startsWith(`${cookName}=`)); 40 | if (temp) { 41 | return parseString(temp); 42 | } 43 | } 44 | return parseString(splitCookie?.[0] ?? ""); // just return the first cookie if no session token is found 45 | }; 46 | 47 | export async function SolidAuthHandler(request: Request, prefix: string, authOptions: SolidAuthConfig) { 48 | const url = new URL(request.url); 49 | const action = url.pathname.slice(prefix.length + 1).split("/")[0] as AuthAction; 50 | 51 | if (!actions.includes(action) || !url.pathname.startsWith(prefix + "/")) { 52 | return; 53 | } 54 | 55 | const res = await Auth(request, authOptions); 56 | if (["callback", "signin", "signout"].includes(action)) { 57 | const parsedCookie = getSetCookieCallback(res.clone().headers.get("Set-Cookie")); 58 | if (parsedCookie) { 59 | res.headers.set( 60 | "Set-Cookie", 61 | serialize(parsedCookie.name, parsedCookie.value, parsedCookie as CookieSerializeOptions), 62 | ); 63 | } 64 | } 65 | return res; 66 | } 67 | -------------------------------------------------------------------------------- /src/server/trpc.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This is your entry point to setup the root configuration for tRPC on the server. 3 | * - `initTRPC` should only be used once per app. 4 | * - We export only the functionality that we use so we can enforce which base procedures should be used 5 | * 6 | * Learn how to create protected base procedures and other things below: 7 | * @see https://trpc.io/docs/v10/router 8 | * @see https://trpc.io/docs/v10/procedures 9 | */ 10 | 11 | import { initTRPC, TRPCError } from "@trpc/server"; 12 | import superjson from "superjson"; 13 | import { ZodError } from "zod"; 14 | import { type Context } from "./context"; 15 | 16 | const t = initTRPC.context().create({ 17 | /** 18 | * @see https://trpc.io/docs/v10/data-transformers 19 | */ 20 | transformer: superjson, 21 | /** 22 | * @see https://trpc.io/docs/v10/error-formatting 23 | */ 24 | errorFormatter({ shape, error }) { 25 | return { 26 | ...shape, 27 | data: { 28 | ...shape.data, 29 | zod: error.cause instanceof ZodError ? error.cause.flatten().fieldErrors : null, 30 | }, 31 | }; 32 | }, 33 | }); 34 | 35 | /** 36 | * Create a router 37 | * @see https://trpc.io/docs/v10/router 38 | */ 39 | export const router = t.router; 40 | 41 | /** 42 | * Create an unprotected procedure 43 | * @see https://trpc.io/docs/v10/procedures 44 | **/ 45 | export const publicProcedure = t.procedure.use((opts) => { 46 | return opts.next({ 47 | ctx: { 48 | user: opts.ctx.user 49 | ? { 50 | id: opts.ctx.user.id, 51 | name: opts.ctx.user.name, 52 | email: opts.ctx.user.email, 53 | image: (opts.ctx.user as { image?: string }).image, 54 | } 55 | : undefined, 56 | }, 57 | }); 58 | }); 59 | 60 | /** 61 | * @see https://trpc.io/docs/v10/middlewares 62 | */ 63 | export const middleware = t.middleware; 64 | 65 | /** 66 | * @see https://trpc.io/docs/v10/merging-routers 67 | */ 68 | export const mergeRouters = t.mergeRouters; 69 | 70 | /** 71 | * Create an private procedure 72 | * @see https://trpc.io/docs/v10/procedures 73 | **/ 74 | export const privateProcedure = t.procedure.use((opts) => { 75 | if (!opts.ctx.user) { 76 | throw new TRPCError({ 77 | code: "UNAUTHORIZED", 78 | message: "You have to be logged in to do this", 79 | }); 80 | } 81 | return opts.next({ 82 | ctx: { 83 | user: opts.ctx.user, 84 | }, 85 | }); 86 | }); 87 | -------------------------------------------------------------------------------- /src/trpc/client/trpc-client.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; 4 | import { httpBatchLink, loggerLink } from "@trpc/client"; 5 | import { createTRPCReact } from "@trpc/react-query"; 6 | import { useState } from "react"; 7 | import superjson from "superjson"; 8 | import type { AppRouter } from "~/server/routers/_app"; 9 | 10 | export const api = createTRPCReact({ 11 | unstable_overrides: { 12 | useMutation: { 13 | async onSuccess(opts) { 14 | await opts.originalFn(); 15 | await opts.queryClient.invalidateQueries(); 16 | }, 17 | }, 18 | }, 19 | }); 20 | 21 | function getBaseUrl() { 22 | if (typeof window !== "undefined") { 23 | // browser should use relative path 24 | return ""; 25 | } 26 | if (process.env.VERCEL_URL) { 27 | // reference for vercel.com 28 | return `https://${process.env.VERCEL_URL}`; 29 | } 30 | if (process.env.RENDER_INTERNAL_HOSTNAME) { 31 | // reference for render.com 32 | const port = process.env.PORT; 33 | if (!port) throw new Error("PORT is not set but RENDER_INTERNAL_HOSTNAME is set"); 34 | return `http://${process.env.RENDER_INTERNAL_HOSTNAME}:${port}`; 35 | } 36 | // assume localhost 37 | return `http://localhost:${process.env.PORT ?? 3000}`; 38 | } 39 | 40 | export function ClientProvider(props: { children: React.ReactNode }) { 41 | const [queryClient] = useState( 42 | () => 43 | new QueryClient({ 44 | defaultOptions: { 45 | queries: { 46 | refetchOnWindowFocus: false, 47 | cacheTime: Infinity, 48 | staleTime: Infinity, 49 | }, 50 | }, 51 | }), 52 | ); 53 | const [trpcClient] = useState(() => 54 | api.createClient({ 55 | links: [ 56 | loggerLink({ 57 | enabled: () => true, 58 | }), 59 | httpBatchLink({ 60 | url: `${getBaseUrl()}/api/trpc`, 61 | }), 62 | ], 63 | transformer: superjson, 64 | }), 65 | ); 66 | return ( 67 | 68 | 69 | {props.children} 70 | {/* This causes "Warning: validateDOMNesting(...):