init commit

This commit is contained in:
2025-10-28 10:57:53 -05:00
commit 693a10957c
109 changed files with 19075 additions and 0 deletions

View File

@@ -0,0 +1 @@
[["1","2","3","4","5","6","7","8","9","10","11","12","13","14","15","16","17","18","19","20","21"],{"key":"22","value":"23"},{"key":"24","value":"25"},{"key":"26","value":"27"},{"key":"28","value":"29"},{"key":"30","value":"31"},{"key":"32","value":"33"},{"key":"34","value":"35"},{"key":"36","value":"37"},{"key":"38","value":"39"},{"key":"40","value":"41"},{"key":"42","value":"43"},{"key":"44","value":"45"},{"key":"46","value":"47"},{"key":"48","value":"49"},{"key":"50","value":"51"},{"key":"52","value":"53"},{"key":"54","value":"55"},{"key":"56","value":"57"},{"key":"58","value":"59"},{"key":"60","value":"61"},{"key":"62","value":"63"},"/home/gib/Documents/Code/monorepo/convex-monorepo/apps/expo/eslint.config.mts",{"size":276,"mtime":1761443987000,"hash":"64","data":"65"},"/home/gib/Documents/Code/monorepo/convex-monorepo/apps/expo/src/app/index.tsx",{"size":5019,"mtime":1761443987000,"hash":"66","data":"67"},"/home/gib/Documents/Code/monorepo/convex-monorepo/apps/expo/app.config.ts",{"size":1333,"mtime":1761443987000,"hash":"68","data":"69"},"/home/gib/Documents/Code/monorepo/convex-monorepo/apps/expo/package.json",{"size":1736,"mtime":1761665033217,"hash":"70","data":"71"},"/home/gib/Documents/Code/monorepo/convex-monorepo/apps/expo/src/app/_layout.tsx",{"size":927,"mtime":1761443987000,"hash":"72","data":"73"},"/home/gib/Documents/Code/monorepo/convex-monorepo/apps/expo/metro.config.js",{"size":511,"mtime":1761443987000,"hash":"74","data":"75"},"/home/gib/Documents/Code/monorepo/convex-monorepo/apps/expo/src/utils/auth.ts",{"size":398,"mtime":1761443987000,"hash":"76","data":"77"},"/home/gib/Documents/Code/monorepo/convex-monorepo/apps/expo/index.ts",{"size":28,"mtime":1761443987000,"hash":"78","data":"79"},"/home/gib/Documents/Code/monorepo/convex-monorepo/apps/expo/src/utils/api.tsx",{"size":1327,"mtime":1761443987000,"hash":"80","data":"81"},"/home/gib/Documents/Code/monorepo/convex-monorepo/apps/expo/src/utils/session-store.ts",{"size":272,"mtime":1761443987000,"hash":"82","data":"83"},"/home/gib/Documents/Code/monorepo/convex-monorepo/apps/expo/turbo.json",{"size":163,"mtime":1761443987000,"hash":"84","data":"85"},"/home/gib/Documents/Code/monorepo/convex-monorepo/apps/expo/assets/icon-light.png",{"size":19133,"mtime":1761443987000,"hash":"86"},"/home/gib/Documents/Code/monorepo/convex-monorepo/apps/expo/eas.json",{"size":567,"mtime":1761443987000,"hash":"87","data":"88"},"/home/gib/Documents/Code/monorepo/convex-monorepo/apps/expo/postcss.config.js",{"size":66,"mtime":1761443987000,"hash":"89","data":"90"},"/home/gib/Documents/Code/monorepo/convex-monorepo/apps/expo/src/app/post/[id].tsx",{"size":757,"mtime":1761443987000,"hash":"91","data":"92"},"/home/gib/Documents/Code/monorepo/convex-monorepo/apps/expo/assets/icon-dark.png",{"size":19633,"mtime":1761443987000,"hash":"93"},"/home/gib/Documents/Code/monorepo/convex-monorepo/apps/expo/src/styles.css",{"size":90,"mtime":1761443987000,"hash":"94","data":"95"},"/home/gib/Documents/Code/monorepo/convex-monorepo/apps/expo/tsconfig.json",{"size":388,"mtime":1761663711587,"hash":"96","data":"97"},"/home/gib/Documents/Code/monorepo/convex-monorepo/apps/expo/src/utils/base-url.ts",{"size":880,"mtime":1761443987000,"hash":"98","data":"99"},"/home/gib/Documents/Code/monorepo/convex-monorepo/apps/expo/.expo-shared/assets.json",{"size":155,"mtime":1761443987000,"hash":"100","data":"101"},"/home/gib/Documents/Code/monorepo/convex-monorepo/apps/expo/nativewind-env.d.ts",{"size":246,"mtime":1761443987000,"hash":"102","data":"103"},"3157b700ecbb4aa217059352507464a2",{"hashOfOptions":"104"},"2be11479779af34f59ec5003513b8e0b",{"hashOfOptions":"105"},"bd476a3950d078330d3e06a560e9f747",{"hashOfOptions":"106"},"f137cf230cf525ca8c2f0bdf233a6e3f",{"hashOfOptions":"107"},"518cd9c33ede87c649b01dd727bb00a0",{"hashOfOptions":"108"},"fbe52c661bc5cbe8d2f7c1058981b896",{"hashOfOptions":"109"},"9455acf80595f4373e11d98e5bc87d89",{"hashOfOptions":"110"},"cc3395d2be4587e21093428fdb6f69e6",{"hashOfOptions":"111"},"0adb5df94af971795b21eddb560c26b5",{"hashOfOptions":"112"},"691dcf997a384f03d1ba5c043c1a3405",{"hashOfOptions":"113"},"c7d4dcf839dfeaa02e0407adfd5e47a6",{"hashOfOptions":"114"},"863da15dbd856008b7c24077ca746d91","a3c1487f8318513ae7c156acc857fde2",{"hashOfOptions":"115"},"5080fb7f49a201ad809050ee57249d41",{"hashOfOptions":"116"},"28454ca5c544a35129bb829072b8dbc1",{"hashOfOptions":"117"},"1e8ac0d261e95efb19d290ffcf70ce36","5068090396cd9f4f9463f09f43c9e257",{"hashOfOptions":"118"},"74d22c2830502b877ac3b806a788bfa3",{"hashOfOptions":"119"},"29e520338914a80764ca0866a87fac3d",{"hashOfOptions":"120"},"0f7f54c7161b8403d3bc42d91f59cd91",{"hashOfOptions":"121"},"d4d589c153ac8b5e7bf0fb130a5b5a7d",{"hashOfOptions":"122"},"3529965326","1818025557","2842754191","1504153565","3815129580","3236408049","1664229517","2979851528","3930068749","2135093465","2923444549","366302476","4133218715","3075567833","3597942159","1044122342","1450990178","3667891651","3412980713"]

View File

@@ -0,0 +1,4 @@
{
"12bb71342c6255bbf50437ec8f4441c083f47cdb74bd89160c15e4f43e52a1cb": true,
"40b842e832070c58deac6aa9e08fa459302ee3f9da492c7e77d93d2fbf4a56fd": true
}

60
apps/expo/app.config.ts Normal file
View File

@@ -0,0 +1,60 @@
import type { ConfigContext, ExpoConfig } from "expo/config";
export default ({ config }: ConfigContext): ExpoConfig => ({
...config,
name: "expo",
slug: "expo",
scheme: "expo",
version: "0.1.0",
orientation: "portrait",
icon: "./assets/icon-light.png",
userInterfaceStyle: "automatic",
updates: {
fallbackToCacheTimeout: 0,
},
newArchEnabled: true,
assetBundlePatterns: ["**/*"],
ios: {
bundleIdentifier: "your.bundle.identifier",
supportsTablet: true,
icon: {
light: "./assets/icon-light.png",
dark: "./assets/icon-dark.png",
},
},
android: {
package: "your.bundle.identifier",
adaptiveIcon: {
foregroundImage: "./assets/icon-light.png",
backgroundColor: "#1F104A",
},
edgeToEdgeEnabled: true,
},
// extra: {
// eas: {
// projectId: "your-eas-project-id",
// },
// },
experiments: {
tsconfigPaths: true,
typedRoutes: true,
reactCanary: true,
reactCompiler: true,
},
plugins: [
"expo-router",
"expo-secure-store",
"expo-web-browser",
[
"expo-splash-screen",
{
backgroundColor: "#E4E4E7",
image: "./assets/icon-light.png",
dark: {
backgroundColor: "#18181B",
image: "./assets/icon-dark.png",
},
},
],
],
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

33
apps/expo/eas.json Normal file
View File

@@ -0,0 +1,33 @@
{
"cli": {
"version": ">= 4.1.2",
"appVersionSource": "remote"
},
"build": {
"base": {
"node": "22.12.0",
"pnpm": "9.15.4",
"ios": {
"resourceClass": "m-medium"
}
},
"development": {
"extends": "base",
"developmentClient": true,
"distribution": "internal"
},
"preview": {
"extends": "base",
"distribution": "internal",
"ios": {
"simulator": true
}
},
"production": {
"extends": "base"
}
},
"submit": {
"production": {}
}
}

View File

@@ -0,0 +1,12 @@
import { defineConfig } from "eslint/config";
import { baseConfig } from "@acme/eslint-config/base";
import { reactConfig } from "@acme/eslint-config/react";
export default defineConfig(
{
ignores: [".expo/**", "expo-plugins/**"],
},
baseConfig,
reactConfig,
);

1
apps/expo/index.ts Normal file
View File

@@ -0,0 +1 @@
import "expo-router/entry";

16
apps/expo/metro.config.js Normal file
View File

@@ -0,0 +1,16 @@
// Learn more: https://docs.expo.dev/guides/monorepos/
const path = require("node:path");
const { getDefaultConfig } = require("expo/metro-config");
const { FileStore } = require("metro-cache");
const { withNativewind } = require("nativewind/metro");
const config = getDefaultConfig(__dirname);
config.cacheStores = [
new FileStore({
root: path.join(__dirname, "node_modules", ".cache", "metro"),
}),
];
/** @type {import('expo/metro-config').MetroConfig} */
module.exports = withNativewind(config);

3
apps/expo/nativewind-env.d.ts vendored Normal file
View File

@@ -0,0 +1,3 @@
/// <reference types="react-native-css/types" />
// NOTE: This file should not be edited and should be committed with your source code. It is generated by react-native-css. If you need to move or disable this file, please see the documentation.

55
apps/expo/package.json Normal file
View File

@@ -0,0 +1,55 @@
{
"name": "@acme/expo",
"private": true,
"main": "index.ts",
"scripts": {
"clean": "git clean -xdf .cache .expo .turbo android ios node_modules",
"dev": "expo start",
"dev:android": "expo start --android",
"dev:ios": "expo start --ios",
"android": "expo run:android",
"ios": "expo run:ios",
"format": "prettier --check . --ignore-path ../../.gitignore --ignore-path .prettierignore",
"lint": "eslint --flag unstable_native_nodejs_ts_config",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@acme/backend": "workspace:*",
"@convex-dev/auth": "workspace:*",
"@legendapp/list": "^2.0.14",
"convex": "workspace:*",
"expo": "~54.0.20",
"expo-constants": "~18.0.10",
"expo-dev-client": "~6.0.16",
"expo-linking": "~8.0.8",
"expo-router": "~6.0.13",
"expo-secure-store": "~15.0.7",
"expo-splash-screen": "~31.0.10",
"expo-status-bar": "~3.0.8",
"expo-system-ui": "~6.0.8",
"expo-web-browser": "~15.0.8",
"nativewind": "5.0.0-preview.2",
"react": "catalog:react19",
"react-dom": "catalog:react19",
"react-native": "~0.81.5",
"react-native-css": "3.0.1",
"react-native-gesture-handler": "~2.28.0",
"react-native-reanimated": "~4.1.3",
"react-native-safe-area-context": "~5.6.1",
"react-native-screens": "~4.16.0",
"react-native-worklets": "~0.5.1",
"superjson": "2.2.3"
},
"devDependencies": {
"@acme/eslint-config": "workspace:*",
"@acme/prettier-config": "workspace:*",
"@acme/tailwind-config": "workspace:*",
"@acme/tsconfig": "workspace:*",
"@types/react": "catalog:react19",
"eslint": "catalog:",
"prettier": "catalog:",
"tailwindcss": "catalog:",
"typescript": "catalog:"
},
"prettier": "@acme/prettier-config"
}

View File

@@ -0,0 +1 @@
module.exports = require("@acme/tailwind-config/postcss-config");

View File

@@ -0,0 +1,33 @@
import { useColorScheme } from "react-native";
import { Stack } from "expo-router";
import { StatusBar } from "expo-status-bar";
import { QueryClientProvider } from "@tanstack/react-query";
import { queryClient } from "~/utils/api";
import "../styles.css";
// This is the main layout of the app
// It wraps your pages with the providers they need
export default function RootLayout() {
const colorScheme = useColorScheme();
return (
<QueryClientProvider client={queryClient}>
{/*
The Stack component displays the current page.
It also allows you to configure your screens
*/}
<Stack
screenOptions={{
headerStyle: {
backgroundColor: "#c03484",
},
contentStyle: {
backgroundColor: colorScheme == "dark" ? "#09090B" : "#FFFFFF",
},
}}
/>
<StatusBar />
</QueryClientProvider>
);
}

172
apps/expo/src/app/index.tsx Normal file
View File

@@ -0,0 +1,172 @@
import { useState } from "react";
import { Pressable, Text, TextInput, View } from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { Link, Stack } from "expo-router";
import { LegendList } from "@legendapp/list";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { RouterOutputs } from "~/utils/api";
import { trpc } from "~/utils/api";
import { authClient } from "~/utils/auth";
function PostCard(props: {
post: RouterOutputs["post"]["all"][number];
onDelete: () => void;
}) {
return (
<View className="bg-muted flex flex-row rounded-lg p-4">
<View className="grow">
<Link
asChild
href={{
pathname: "/post/[id]",
params: { id: props.post.id },
}}
>
<Pressable className="">
<Text className="text-primary text-xl font-semibold">
{props.post.title}
</Text>
<Text className="text-foreground mt-2">{props.post.content}</Text>
</Pressable>
</Link>
</View>
<Pressable onPress={props.onDelete}>
<Text className="text-primary font-bold uppercase">Delete</Text>
</Pressable>
</View>
);
}
function CreatePost() {
const queryClient = useQueryClient();
const [title, setTitle] = useState("");
const [content, setContent] = useState("");
const { mutate, error } = useMutation(
trpc.post.create.mutationOptions({
async onSuccess() {
setTitle("");
setContent("");
await queryClient.invalidateQueries(trpc.post.all.queryFilter());
},
}),
);
return (
<View className="mt-4 flex gap-2">
<TextInput
className="border-input bg-background text-foreground items-center rounded-md border px-3 text-lg leading-tight"
value={title}
onChangeText={setTitle}
placeholder="Title"
/>
{error?.data?.zodError?.fieldErrors.title && (
<Text className="text-destructive mb-2">
{error.data.zodError.fieldErrors.title}
</Text>
)}
<TextInput
className="border-input bg-background text-foreground items-center rounded-md border px-3 text-lg leading-tight"
value={content}
onChangeText={setContent}
placeholder="Content"
/>
{error?.data?.zodError?.fieldErrors.content && (
<Text className="text-destructive mb-2">
{error.data.zodError.fieldErrors.content}
</Text>
)}
<Pressable
className="bg-primary flex items-center rounded-sm p-2"
onPress={() => {
mutate({
title,
content,
});
}}
>
<Text className="text-foreground">Create</Text>
</Pressable>
{error?.data?.code === "UNAUTHORIZED" && (
<Text className="text-destructive mt-2">
You need to be logged in to create a post
</Text>
)}
</View>
);
}
function MobileAuth() {
const { data: session } = authClient.useSession();
return (
<>
<Text className="text-foreground pb-2 text-center text-xl font-semibold">
{session?.user.name ? `Hello, ${session.user.name}` : "Not logged in"}
</Text>
<Pressable
onPress={() =>
session
? authClient.signOut()
: authClient.signIn.social({
provider: "discord",
callbackURL: "/",
})
}
className="bg-primary flex items-center rounded-sm p-2"
>
<Text>{session ? "Sign Out" : "Sign In With Discord"}</Text>
</Pressable>
</>
);
}
export default function Index() {
const queryClient = useQueryClient();
const postQuery = useQuery(trpc.post.all.queryOptions());
const deletePostMutation = useMutation(
trpc.post.delete.mutationOptions({
onSettled: () =>
queryClient.invalidateQueries(trpc.post.all.queryFilter()),
}),
);
return (
<SafeAreaView className="bg-background">
{/* Changes page title visible on the header */}
<Stack.Screen options={{ title: "Home Page" }} />
<View className="bg-background h-full w-full p-4">
<Text className="text-foreground pb-2 text-center text-5xl font-bold">
Create <Text className="text-primary">T3</Text> Turbo
</Text>
<MobileAuth />
<View className="py-2">
<Text className="text-primary font-semibold italic">
Press on a post
</Text>
</View>
<LegendList
data={postQuery.data ?? []}
estimatedItemSize={20}
keyExtractor={(item) => item.id}
ItemSeparatorComponent={() => <View className="h-2" />}
renderItem={(p) => (
<PostCard
post={p.item}
onDelete={() => deletePostMutation.mutate(p.item.id)}
/>
)}
/>
<CreatePost />
</View>
</SafeAreaView>
);
}

View File

@@ -0,0 +1,24 @@
import { SafeAreaView, Text, View } from "react-native";
import { Stack, useGlobalSearchParams } from "expo-router";
import { useQuery } from "@tanstack/react-query";
import { trpc } from "~/utils/api";
export default function Post() {
const { id } = useGlobalSearchParams<{ id: string }>();
const { data } = useQuery(trpc.post.byId.queryOptions({ id }));
if (!data) return null;
return (
<SafeAreaView className="bg-background">
<Stack.Screen options={{ title: data.title }} />
<View className="h-full w-full p-4">
<Text className="text-primary py-2 text-3xl font-bold">
{data.title}
</Text>
<Text className="text-foreground py-4">{data.content}</Text>
</View>
</SafeAreaView>
);
}

3
apps/expo/src/styles.css Normal file
View File

@@ -0,0 +1,3 @@
@import "tailwindcss";
@import "nativewind/theme";
@import "@acme/tailwind-config/theme";

View File

@@ -0,0 +1,50 @@
import { QueryClient } from "@tanstack/react-query";
import { createTRPCClient, httpBatchLink, loggerLink } from "@trpc/client";
import { createTRPCOptionsProxy } from "@trpc/tanstack-react-query";
import superjson from "superjson";
import type { AppRouter } from "@acme/api";
import { authClient } from "./auth";
import { getBaseUrl } from "./base-url";
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
// ...
},
},
});
/**
* A set of typesafe hooks for consuming your API.
*/
export const trpc = createTRPCOptionsProxy<AppRouter>({
client: createTRPCClient({
links: [
loggerLink({
enabled: (opts) =>
process.env.NODE_ENV === "development" ||
(opts.direction === "down" && opts.result instanceof Error),
colorMode: "ansi",
}),
httpBatchLink({
transformer: superjson,
url: `${getBaseUrl()}/api/trpc`,
headers() {
const headers = new Map<string, string>();
headers.set("x-trpc-source", "expo-react");
const cookies = authClient.getCookie();
if (cookies) {
headers.set("Cookie", cookies);
}
return headers;
},
}),
],
}),
queryClient,
});
export type { RouterInputs, RouterOutputs } from "@acme/api";

View File

@@ -0,0 +1,16 @@
import * as SecureStore from "expo-secure-store";
import { expoClient } from "@better-auth/expo/client";
import { createAuthClient } from "better-auth/react";
import { getBaseUrl } from "./base-url";
export const authClient = createAuthClient({
baseURL: getBaseUrl(),
plugins: [
expoClient({
scheme: "expo",
storagePrefix: "expo",
storage: SecureStore,
}),
],
});

View File

@@ -0,0 +1,26 @@
import Constants from "expo-constants";
/**
* Extend this function when going to production by
* setting the baseUrl to your production API URL.
*/
export const getBaseUrl = () => {
/**
* Gets the IP address of your host-machine. If it cannot automatically find it,
* you'll have to manually set it. NOTE: Port 3000 should work for most but confirm
* you don't have anything else running on it, or you'd have to change it.
*
* **NOTE**: This is only for development. In production, you'll want to set the
* baseUrl to your production API URL.
*/
const debuggerHost = Constants.expoConfig?.hostUri;
const localhost = debuggerHost?.split(":")[0];
if (!localhost) {
// return "https://turbo.t3.gg";
throw new Error(
"Failed to get localhost. Please point to your production server.",
);
}
return `http://${localhost}:3000`;
};

View File

@@ -0,0 +1,7 @@
import * as SecureStore from "expo-secure-store";
const key = "session_token";
export const getToken = () => SecureStore.getItem(key);
export const deleteToken = () => SecureStore.deleteItemAsync(key);
export const setToken = (v: string) => SecureStore.setItem(key, v);

20
apps/expo/tsconfig.json Normal file
View File

@@ -0,0 +1,20 @@
{
"extends": ["@acme/tsconfig/base.json"],
"compilerOptions": {
"jsx": "react-native",
"checkJs": false,
"moduleSuffixes": [".ios", ".android", ".native", ""],
"paths": {
"@/*": ["./src/*"]
}
},
"include": [
"src",
"*.ts",
"*.js",
".expo/types/**/*.ts",
"expo-env.d.ts",
"nativewind-env.d.ts"
],
"exclude": ["node_modules"]
}

10
apps/expo/turbo.json Normal file
View File

@@ -0,0 +1,10 @@
{
"$schema": "https://turborepo.com/schema.json",
"extends": ["//"],
"tasks": {
"dev": {
"persistent": true,
"interactive": true
}
}
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,15 @@
import { defineConfig } from "eslint/config";
import { baseConfig, restrictEnvAccess } from "@acme/eslint-config/base";
import { nextjsConfig } from "@acme/eslint-config/nextjs";
import { reactConfig } from "@acme/eslint-config/react";
export default defineConfig(
{
ignores: [".next/**"],
},
baseConfig,
reactConfig,
nextjsConfig,
restrictEnvAccess,
);

View File

@@ -0,0 +1,23 @@
import { createJiti } from "jiti";
const jiti = createJiti(import.meta.url);
// Import env files to validate at build time. Use jiti so we can load .ts files in here.
await jiti.import("./src/env");
/** @type {import("next").NextConfig} */
const config = {
/** Enables hot reloading for local packages without a build step */
transpilePackages: [
"@acme/api",
"@acme/auth",
"@acme/db",
"@acme/ui",
"@acme/validators",
],
/** We already do linting and typechecking as separate tasks in CI */
typescript: { ignoreBuildErrors: true },
};
export default config;

43
apps/nextjs/package.json Normal file
View File

@@ -0,0 +1,43 @@
{
"name": "@acme/nextjs",
"private": true,
"type": "module",
"scripts": {
"build": "pnpm with-env next build",
"clean": "git clean -xdf .cache .next .turbo node_modules",
"dev": "pnpm with-env next dev",
"format": "prettier --check . --ignore-path ../../.gitignore",
"lint": "eslint --flag unstable_native_nodejs_ts_config",
"start": "pnpm with-env next start",
"typecheck": "tsc --noEmit",
"with-env": "dotenv -e ../../.env --"
},
"dependencies": {
"@acme/backend": "workspace:*",
"@acme/ui": "workspace:*",
"@convex-dev/auth": "workspace:*",
"@t3-oss/env-nextjs": "^0.13.8",
"convex": "workspace:*",
"next": "^16.0.0",
"react": "catalog:react19",
"react-dom": "catalog:react19",
"superjson": "2.2.3",
"zod": "catalog:"
},
"devDependencies": {
"@acme/eslint-config": "workspace:*",
"@acme/prettier-config": "workspace:*",
"@acme/tailwind-config": "workspace:*",
"@acme/tsconfig": "workspace:*",
"@types/node": "catalog:",
"@types/react": "catalog:react19",
"@types/react-dom": "catalog:react19",
"eslint": "catalog:",
"jiti": "^2.5.1",
"prettier": "catalog:",
"tailwindcss": "catalog:",
"tw-animate-css": "^1.4.0",
"typescript": "catalog:"
},
"prettier": "@acme/prettier-config"
}

View File

@@ -0,0 +1 @@
export { default } from "@acme/tailwind-config/postcss-config";

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

View File

@@ -0,0 +1,13 @@
<svg width="258" height="198" viewBox="0 0 258 198" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_1_12)">
<path d="M165.269 24.0976L188.481 -0.000411987H0V24.0976H165.269Z" fill="black"/>
<path d="M163.515 95.3516L253.556 2.71059H220.74L145.151 79.7886L163.515 95.3516Z" fill="black"/>
<path d="M233.192 130.446C233.192 154.103 214.014 173.282 190.357 173.282C171.249 173.282 155.047 160.766 149.534 143.467L146.159 132.876L126.863 152.171L128.626 156.364C138.749 180.449 162.568 197.382 190.357 197.382C227.325 197.382 257.293 167.414 257.293 130.446C257.293 105.965 243.933 84.7676 224.49 73.1186L219.929 70.3856L202.261 88.2806L210.322 92.5356C223.937 99.7236 233.192 114.009 233.192 130.446Z" fill="black"/>
<path d="M87.797 191.697V44.6736H63.699V191.697H87.797Z" fill="black"/>
</g>
<defs>
<clipPath id="clip0_1_12">
<rect width="258" height="198" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 923 B

View File

@@ -0,0 +1,58 @@
import { headers } from "next/headers";
import { redirect } from "next/navigation";
import { Button } from "@acme/ui/button";
import { auth, getSession } from "~/auth/server";
export async function AuthShowcase() {
const session = await getSession();
if (!session) {
return (
<form>
<Button
size="lg"
formAction={async () => {
"use server";
const res = await auth.api.signInSocial({
body: {
provider: "discord",
callbackURL: "/",
},
});
if (!res.url) {
throw new Error("No URL returned from signInSocial");
}
redirect(res.url);
}}
>
Sign in with Discord
</Button>
</form>
);
}
return (
<div className="flex flex-col items-center justify-center gap-4">
<p className="text-center text-2xl">
<span>Logged in as {session.user.name}</span>
</p>
<form>
<Button
size="lg"
formAction={async () => {
"use server";
await auth.api.signOut({
headers: await headers(),
});
redirect("/");
}}
>
Sign out
</Button>
</form>
</div>
);
}

View File

@@ -0,0 +1,210 @@
"use client";
import { useForm } from "@tanstack/react-form";
import {
useMutation,
useQueryClient,
useSuspenseQuery,
} from "@tanstack/react-query";
import type { RouterOutputs } from "@acme/api";
import { CreatePostSchema } from "@acme/db/schema";
import { cn } from "@acme/ui";
import { Button } from "@acme/ui/button";
import {
Field,
FieldContent,
FieldError,
FieldGroup,
FieldLabel,
} from "@acme/ui/field";
import { Input } from "@acme/ui/input";
import { toast } from "@acme/ui/toast";
import { useTRPC } from "~/trpc/react";
export function CreatePostForm() {
const trpc = useTRPC();
const queryClient = useQueryClient();
const createPost = useMutation(
trpc.post.create.mutationOptions({
onSuccess: async () => {
form.reset();
await queryClient.invalidateQueries(trpc.post.pathFilter());
},
onError: (err) => {
toast.error(
err.data?.code === "UNAUTHORIZED"
? "You must be logged in to post"
: "Failed to create post",
);
},
}),
);
const form = useForm({
defaultValues: {
content: "",
title: "",
},
validators: {
onSubmit: CreatePostSchema,
},
onSubmit: (data) => createPost.mutate(data.value),
});
return (
<form
className="w-full max-w-2xl"
onSubmit={(event) => {
event.preventDefault();
void form.handleSubmit();
}}
>
<FieldGroup>
<form.Field
name="title"
children={(field) => {
const isInvalid =
field.state.meta.isTouched && !field.state.meta.isValid;
return (
<Field data-invalid={isInvalid}>
<FieldContent>
<FieldLabel htmlFor={field.name}>Bug Title</FieldLabel>
</FieldContent>
<Input
id={field.name}
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
aria-invalid={isInvalid}
placeholder="Title"
/>
{isInvalid && <FieldError errors={field.state.meta.errors} />}
</Field>
);
}}
/>
<form.Field
name="content"
children={(field) => {
const isInvalid =
field.state.meta.isTouched && !field.state.meta.isValid;
return (
<Field data-invalid={isInvalid}>
<FieldContent>
<FieldLabel htmlFor={field.name}>Content</FieldLabel>
</FieldContent>
<Input
id={field.name}
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
aria-invalid={isInvalid}
placeholder="Content"
/>
{isInvalid && <FieldError errors={field.state.meta.errors} />}
</Field>
);
}}
/>
</FieldGroup>
<Button type="submit">Create</Button>
</form>
);
}
export function PostList() {
const trpc = useTRPC();
const { data: posts } = useSuspenseQuery(trpc.post.all.queryOptions());
if (posts.length === 0) {
return (
<div className="relative flex w-full flex-col gap-4">
<PostCardSkeleton pulse={false} />
<PostCardSkeleton pulse={false} />
<PostCardSkeleton pulse={false} />
<div className="absolute inset-0 flex flex-col items-center justify-center bg-black/10">
<p className="text-2xl font-bold text-white">No posts yet</p>
</div>
</div>
);
}
return (
<div className="flex w-full flex-col gap-4">
{posts.map((p) => {
return <PostCard key={p.id} post={p} />;
})}
</div>
);
}
export function PostCard(props: {
post: RouterOutputs["post"]["all"][number];
}) {
const trpc = useTRPC();
const queryClient = useQueryClient();
const deletePost = useMutation(
trpc.post.delete.mutationOptions({
onSuccess: async () => {
await queryClient.invalidateQueries(trpc.post.pathFilter());
},
onError: (err) => {
toast.error(
err.data?.code === "UNAUTHORIZED"
? "You must be logged in to delete a post"
: "Failed to delete post",
);
},
}),
);
return (
<div className="bg-muted flex flex-row rounded-lg p-4">
<div className="grow">
<h2 className="text-primary text-2xl font-bold">{props.post.title}</h2>
<p className="mt-2 text-sm">{props.post.content}</p>
</div>
<div>
<Button
variant="ghost"
className="text-primary cursor-pointer text-sm font-bold uppercase hover:bg-transparent hover:text-white"
onClick={() => deletePost.mutate(props.post.id)}
>
Delete
</Button>
</div>
</div>
);
}
export function PostCardSkeleton(props: { pulse?: boolean }) {
const { pulse = true } = props;
return (
<div className="bg-muted flex flex-row rounded-lg p-4">
<div className="grow">
<h2
className={cn(
"bg-primary w-1/4 rounded-sm text-2xl font-bold",
pulse && "animate-pulse",
)}
>
&nbsp;
</h2>
<p
className={cn(
"mt-2 w-1/3 rounded-sm bg-current text-sm",
pulse && "animate-pulse",
)}
>
&nbsp;
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,4 @@
import { auth } from "~/auth/server";
export const GET = auth.handler;
export const POST = auth.handler;

View File

@@ -0,0 +1,46 @@
import type { NextRequest } from "next/server";
import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
import { appRouter, createTRPCContext } from "@acme/api";
import { auth } from "~/auth/server";
/**
* Configure basic CORS headers
* You should extend this to match your needs
*/
const setCorsHeaders = (res: Response) => {
res.headers.set("Access-Control-Allow-Origin", "*");
res.headers.set("Access-Control-Request-Method", "*");
res.headers.set("Access-Control-Allow-Methods", "OPTIONS, GET, POST");
res.headers.set("Access-Control-Allow-Headers", "*");
};
export const OPTIONS = () => {
const response = new Response(null, {
status: 204,
});
setCorsHeaders(response);
return response;
};
const handler = async (req: NextRequest) => {
const response = await fetchRequestHandler({
endpoint: "/api/trpc",
router: appRouter,
req,
createContext: () =>
createTRPCContext({
auth: auth,
headers: req.headers,
}),
onError({ error, path }) {
console.error(`>>> tRPC Error on '${path}'`, error);
},
});
setCorsHeaders(response);
return response;
};
export { handler as GET, handler as POST };

View File

@@ -0,0 +1,70 @@
import type { Metadata, Viewport } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import { cn } from "@acme/ui";
import { ThemeProvider, ThemeToggle } from "@acme/ui/theme";
import { Toaster } from "@acme/ui/toast";
import { env } from "~/env";
import { TRPCReactProvider } from "~/trpc/react";
import "~/app/styles.css";
export const metadata: Metadata = {
metadataBase: new URL(
env.VERCEL_ENV === "production"
? "https://turbo.t3.gg"
: "http://localhost:3000",
),
title: "Create T3 Turbo",
description: "Simple monorepo with shared backend for web & mobile apps",
openGraph: {
title: "Create T3 Turbo",
description: "Simple monorepo with shared backend for web & mobile apps",
url: "https://create-t3-turbo.vercel.app",
siteName: "Create T3 Turbo",
},
twitter: {
card: "summary_large_image",
site: "@jullerino",
creator: "@jullerino",
},
};
export const viewport: Viewport = {
themeColor: [
{ media: "(prefers-color-scheme: light)", color: "white" },
{ media: "(prefers-color-scheme: dark)", color: "black" },
],
};
const geistSans = Geist({
subsets: ["latin"],
variable: "--font-geist-sans",
});
const geistMono = Geist_Mono({
subsets: ["latin"],
variable: "--font-geist-mono",
});
export default function RootLayout(props: { children: React.ReactNode }) {
return (
<html lang="en" suppressHydrationWarning>
<body
className={cn(
"bg-background text-foreground min-h-screen font-sans antialiased",
geistSans.variable,
geistMono.variable,
)}
>
<ThemeProvider>
<TRPCReactProvider>{props.children}</TRPCReactProvider>
<div className="absolute right-4 bottom-4">
<ThemeToggle />
</div>
<Toaster />
</ThemeProvider>
</body>
</html>
);
}

View File

@@ -0,0 +1,41 @@
import { Suspense } from "react";
import { HydrateClient, prefetch, trpc } from "~/trpc/server";
import { AuthShowcase } from "./_components/auth-showcase";
import {
CreatePostForm,
PostCardSkeleton,
PostList,
} from "./_components/posts";
export default function HomePage() {
prefetch(trpc.post.all.queryOptions());
return (
<HydrateClient>
<main className="container h-screen py-16">
<div className="flex flex-col items-center justify-center gap-4">
<h1 className="text-5xl font-extrabold tracking-tight sm:text-[5rem]">
Create <span className="text-primary">T3</span> Turbo
</h1>
<AuthShowcase />
<CreatePostForm />
<div className="w-full max-w-2xl overflow-y-scroll">
<Suspense
fallback={
<div className="flex w-full flex-col gap-4">
<PostCardSkeleton />
<PostCardSkeleton />
<PostCardSkeleton />
</div>
}
>
<PostList />
</Suspense>
</div>
</div>
</main>
</HydrateClient>
);
}

View File

@@ -0,0 +1,29 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "@acme/tailwind-config/theme";
@source "../../../../packages/ui/src/*.{ts,tsx}";
@custom-variant dark (&:where(.dark, .dark *));
@custom-variant light (&:where(.light, .light *));
@custom-variant auto (&:where(.auto, .auto *));
@utility container {
margin-inline: auto;
padding-inline: 2rem;
@media (width >= --theme(--breakpoint-sm)) {
max-width: none;
}
@media (width >= 1400px) {
max-width: 1400px;
}
}
@layer base {
* {
@apply border-border;
}
body {
letter-spacing: var(--tracking-normal);
}
}

View File

@@ -0,0 +1,3 @@
import { createAuthClient } from "better-auth/react";
export const authClient = createAuthClient();

View File

@@ -0,0 +1,29 @@
import "server-only";
import { cache } from "react";
import { headers } from "next/headers";
import { nextCookies } from "better-auth/next-js";
import { initAuth } from "@acme/auth";
import { env } from "~/env";
const baseUrl =
env.VERCEL_ENV === "production"
? `https://${env.VERCEL_PROJECT_PRODUCTION_URL}`
: env.VERCEL_ENV === "preview"
? `https://${env.VERCEL_URL}`
: "http://localhost:3000";
export const auth = initAuth({
baseUrl,
productionUrl: `https://${env.VERCEL_PROJECT_PRODUCTION_URL ?? "turbo.t3.gg"}`,
secret: env.AUTH_SECRET,
discordClientId: env.AUTH_DISCORD_ID,
discordClientSecret: env.AUTH_DISCORD_SECRET,
extraPlugins: [nextCookies()],
});
export const getSession = cache(async () =>
auth.api.getSession({ headers: await headers() }),
);

39
apps/nextjs/src/env.ts Normal file
View File

@@ -0,0 +1,39 @@
import { createEnv } from "@t3-oss/env-nextjs";
import { vercel } from "@t3-oss/env-nextjs/presets-zod";
import { z } from "zod/v4";
import { authEnv } from "@acme/auth/env";
export const env = createEnv({
extends: [authEnv(), vercel()],
shared: {
NODE_ENV: z
.enum(["development", "production", "test"])
.default("development"),
},
/**
* Specify your server-side environment variables schema here.
* This way you can ensure the app isn't built with invalid env vars.
*/
server: {
POSTGRES_URL: z.url(),
},
/**
* Specify your client-side environment variables schema here.
* For them to be exposed to the client, prefix them with `NEXT_PUBLIC_`.
*/
client: {
// NEXT_PUBLIC_CLIENTVAR: z.string(),
},
/**
* Destructure all variables from `process.env` to make sure they aren't tree-shaken away.
*/
experimental__runtimeEnv: {
NODE_ENV: process.env.NODE_ENV,
// NEXT_PUBLIC_CLIENTVAR: process.env.NEXT_PUBLIC_CLIENTVAR,
},
skipValidation:
!!process.env.CI || process.env.npm_lifecycle_event === "lint",
});

View File

@@ -0,0 +1,33 @@
import {
defaultShouldDehydrateQuery,
QueryClient,
} from "@tanstack/react-query";
import SuperJSON from "superjson";
export const createQueryClient = () =>
new QueryClient({
defaultOptions: {
queries: {
// With SSR, we usually want to set some default staleTime
// above 0 to avoid refetching immediately on the client
staleTime: 30 * 1000,
},
dehydrate: {
serializeData: SuperJSON.serialize,
shouldDehydrateQuery: (query) =>
defaultShouldDehydrateQuery(query) ||
query.state.status === "pending",
shouldRedactErrors: () => {
// We should not catch Next.js server errors
// as that's how Next.js detects dynamic pages
// so we cannot redact them.
// Next.js also automatically redacts errors for us
// with better digests.
return false;
},
},
hydrate: {
deserializeData: SuperJSON.deserialize,
},
},
});

View File

@@ -0,0 +1,70 @@
"use client";
import type { QueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { QueryClientProvider } from "@tanstack/react-query";
import {
createTRPCClient,
httpBatchStreamLink,
loggerLink,
} from "@trpc/client";
import { createTRPCContext } from "@trpc/tanstack-react-query";
import SuperJSON from "superjson";
import type { AppRouter } from "@acme/api";
import { env } from "~/env";
import { createQueryClient } from "./query-client";
let clientQueryClientSingleton: QueryClient | undefined = undefined;
const getQueryClient = () => {
if (typeof window === "undefined") {
// Server: always make a new query client
return createQueryClient();
} else {
// Browser: use singleton pattern to keep the same query client
return (clientQueryClientSingleton ??= createQueryClient());
}
};
export const { useTRPC, TRPCProvider } = createTRPCContext<AppRouter>();
export function TRPCReactProvider(props: { children: React.ReactNode }) {
const queryClient = getQueryClient();
const [trpcClient] = useState(() =>
createTRPCClient<AppRouter>({
links: [
loggerLink({
enabled: (op) =>
env.NODE_ENV === "development" ||
(op.direction === "down" && op.result instanceof Error),
}),
httpBatchStreamLink({
transformer: SuperJSON,
url: getBaseUrl() + "/api/trpc",
headers() {
const headers = new Headers();
headers.set("x-trpc-source", "nextjs-react");
return headers;
},
}),
],
}),
);
return (
<QueryClientProvider client={queryClient}>
<TRPCProvider trpcClient={trpcClient} queryClient={queryClient}>
{props.children}
</TRPCProvider>
</QueryClientProvider>
);
}
const getBaseUrl = () => {
if (typeof window !== "undefined") return window.location.origin;
if (env.VERCEL_URL) return `https://${env.VERCEL_URL}`;
// eslint-disable-next-line no-restricted-properties
return `http://localhost:${process.env.PORT ?? 3000}`;
};

View File

@@ -0,0 +1,54 @@
import type { TRPCQueryOptions } from "@trpc/tanstack-react-query";
import { cache } from "react";
import { headers } from "next/headers";
import { dehydrate, HydrationBoundary } from "@tanstack/react-query";
import { createTRPCOptionsProxy } from "@trpc/tanstack-react-query";
import type { AppRouter } from "@acme/api";
import { appRouter, createTRPCContext } from "@acme/api";
import { auth } from "~/auth/server";
import { createQueryClient } from "./query-client";
/**
* This wraps the `createTRPCContext` helper and provides the required context for the tRPC API when
* handling a tRPC call from a React Server Component.
*/
const createContext = cache(async () => {
const heads = new Headers(await headers());
heads.set("x-trpc-source", "rsc");
return createTRPCContext({
headers: heads,
auth,
});
});
const getQueryClient = cache(createQueryClient);
export const trpc = createTRPCOptionsProxy<AppRouter>({
router: appRouter,
ctx: createContext,
queryClient: getQueryClient,
});
export function HydrateClient(props: { children: React.ReactNode }) {
const queryClient = getQueryClient();
return (
<HydrationBoundary state={dehydrate(queryClient)}>
{props.children}
</HydrationBoundary>
);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function prefetch<T extends ReturnType<TRPCQueryOptions<any>>>(
queryOptions: T,
) {
const queryClient = getQueryClient();
if (queryOptions.queryKey[1]?.type === "infinite") {
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-explicit-any
void queryClient.prefetchInfiniteQuery(queryOptions as any);
} else {
void queryClient.prefetchQuery(queryOptions);
}
}

13
apps/nextjs/tsconfig.json Normal file
View File

@@ -0,0 +1,13 @@
{
"extends": "@acme/tsconfig/base.json",
"compilerOptions": {
"lib": ["ES2022", "dom", "dom.iterable"],
"jsx": "preserve",
"paths": {
"@/*": ["./src/*"]
},
"plugins": [{ "name": "next" }]
},
"include": [".", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}

13
apps/nextjs/turbo.json Normal file
View File

@@ -0,0 +1,13 @@
{
"$schema": "https://turborepo.com/schema.json",
"extends": ["//"],
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": [".next/**", "!.next/cache/**", "next-env.d.ts"]
},
"dev": {
"persistent": true
}
}
}