init commit
This commit is contained in:
1
apps/expo/.cache/.prettiercache
Normal file
1
apps/expo/.cache/.prettiercache
Normal 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"]
|
||||
4
apps/expo/.expo-shared/assets.json
Normal file
4
apps/expo/.expo-shared/assets.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"12bb71342c6255bbf50437ec8f4441c083f47cdb74bd89160c15e4f43e52a1cb": true,
|
||||
"40b842e832070c58deac6aa9e08fa459302ee3f9da492c7e77d93d2fbf4a56fd": true
|
||||
}
|
||||
60
apps/expo/app.config.ts
Normal file
60
apps/expo/app.config.ts
Normal 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",
|
||||
},
|
||||
},
|
||||
],
|
||||
],
|
||||
});
|
||||
BIN
apps/expo/assets/icon-dark.png
Normal file
BIN
apps/expo/assets/icon-dark.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 19 KiB |
BIN
apps/expo/assets/icon-light.png
Normal file
BIN
apps/expo/assets/icon-light.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 19 KiB |
33
apps/expo/eas.json
Normal file
33
apps/expo/eas.json
Normal 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": {}
|
||||
}
|
||||
}
|
||||
12
apps/expo/eslint.config.mts
Normal file
12
apps/expo/eslint.config.mts
Normal 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
1
apps/expo/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
import "expo-router/entry";
|
||||
16
apps/expo/metro.config.js
Normal file
16
apps/expo/metro.config.js
Normal 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
3
apps/expo/nativewind-env.d.ts
vendored
Normal 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
55
apps/expo/package.json
Normal 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"
|
||||
}
|
||||
1
apps/expo/postcss.config.js
Normal file
1
apps/expo/postcss.config.js
Normal file
@@ -0,0 +1 @@
|
||||
module.exports = require("@acme/tailwind-config/postcss-config");
|
||||
33
apps/expo/src/app/_layout.tsx
Normal file
33
apps/expo/src/app/_layout.tsx
Normal 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
172
apps/expo/src/app/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
24
apps/expo/src/app/post/[id].tsx
Normal file
24
apps/expo/src/app/post/[id].tsx
Normal 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
3
apps/expo/src/styles.css
Normal file
@@ -0,0 +1,3 @@
|
||||
@import "tailwindcss";
|
||||
@import "nativewind/theme";
|
||||
@import "@acme/tailwind-config/theme";
|
||||
50
apps/expo/src/utils/api.tsx
Normal file
50
apps/expo/src/utils/api.tsx
Normal 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";
|
||||
16
apps/expo/src/utils/auth.ts
Normal file
16
apps/expo/src/utils/auth.ts
Normal 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,
|
||||
}),
|
||||
],
|
||||
});
|
||||
26
apps/expo/src/utils/base-url.ts
Normal file
26
apps/expo/src/utils/base-url.ts
Normal 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`;
|
||||
};
|
||||
7
apps/expo/src/utils/session-store.ts
Normal file
7
apps/expo/src/utils/session-store.ts
Normal 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
20
apps/expo/tsconfig.json
Normal 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
10
apps/expo/turbo.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"$schema": "https://turborepo.com/schema.json",
|
||||
"extends": ["//"],
|
||||
"tasks": {
|
||||
"dev": {
|
||||
"persistent": true,
|
||||
"interactive": true
|
||||
}
|
||||
}
|
||||
}
|
||||
1
apps/nextjs/.cache/.prettiercache
Normal file
1
apps/nextjs/.cache/.prettiercache
Normal file
File diff suppressed because one or more lines are too long
15
apps/nextjs/eslint.config.ts
Normal file
15
apps/nextjs/eslint.config.ts
Normal 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,
|
||||
);
|
||||
23
apps/nextjs/next.config.js
Normal file
23
apps/nextjs/next.config.js
Normal 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
43
apps/nextjs/package.json
Normal 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"
|
||||
}
|
||||
1
apps/nextjs/postcss.config.js
Normal file
1
apps/nextjs/postcss.config.js
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from "@acme/tailwind-config/postcss-config";
|
||||
BIN
apps/nextjs/public/favicon.ico
Normal file
BIN
apps/nextjs/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 101 KiB |
13
apps/nextjs/public/t3-icon.svg
Normal file
13
apps/nextjs/public/t3-icon.svg
Normal 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 |
58
apps/nextjs/src/app/_components/auth-showcase.tsx
Normal file
58
apps/nextjs/src/app/_components/auth-showcase.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
210
apps/nextjs/src/app/_components/posts.tsx
Normal file
210
apps/nextjs/src/app/_components/posts.tsx
Normal 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",
|
||||
)}
|
||||
>
|
||||
|
||||
</h2>
|
||||
<p
|
||||
className={cn(
|
||||
"mt-2 w-1/3 rounded-sm bg-current text-sm",
|
||||
pulse && "animate-pulse",
|
||||
)}
|
||||
>
|
||||
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
4
apps/nextjs/src/app/api/auth/[...all]/route.ts
Normal file
4
apps/nextjs/src/app/api/auth/[...all]/route.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { auth } from "~/auth/server";
|
||||
|
||||
export const GET = auth.handler;
|
||||
export const POST = auth.handler;
|
||||
46
apps/nextjs/src/app/api/trpc/[trpc]/route.ts
Normal file
46
apps/nextjs/src/app/api/trpc/[trpc]/route.ts
Normal 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 };
|
||||
70
apps/nextjs/src/app/layout.tsx
Normal file
70
apps/nextjs/src/app/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
41
apps/nextjs/src/app/page.tsx
Normal file
41
apps/nextjs/src/app/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
29
apps/nextjs/src/app/styles.css
Normal file
29
apps/nextjs/src/app/styles.css
Normal 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);
|
||||
}
|
||||
}
|
||||
3
apps/nextjs/src/auth/client.ts
Normal file
3
apps/nextjs/src/auth/client.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { createAuthClient } from "better-auth/react";
|
||||
|
||||
export const authClient = createAuthClient();
|
||||
29
apps/nextjs/src/auth/server.ts
Normal file
29
apps/nextjs/src/auth/server.ts
Normal 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
39
apps/nextjs/src/env.ts
Normal 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",
|
||||
});
|
||||
33
apps/nextjs/src/trpc/query-client.ts
Normal file
33
apps/nextjs/src/trpc/query-client.ts
Normal 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,
|
||||
},
|
||||
},
|
||||
});
|
||||
70
apps/nextjs/src/trpc/react.tsx
Normal file
70
apps/nextjs/src/trpc/react.tsx
Normal 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}`;
|
||||
};
|
||||
54
apps/nextjs/src/trpc/server.tsx
Normal file
54
apps/nextjs/src/trpc/server.tsx
Normal 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
13
apps/nextjs/tsconfig.json
Normal 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
13
apps/nextjs/turbo.json
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user