mirror of
https://github.com/FranP-code/create-better-t-stack.git
synced 2025-10-12 23:52:15 +00:00
Added support for building mobile applications with Expo
This commit is contained in:
5
.changeset/khaki-suns-sit.md
Normal file
5
.changeset/khaki-suns-sit.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"create-better-t-stack": minor
|
||||
---
|
||||
|
||||
Added support for building mobile applications with Expo
|
||||
@@ -20,10 +20,11 @@ pnpm create better-t-stack@latest
|
||||
- ⚡️ **Zero-config setup** with interactive CLI wizard
|
||||
- 🔄 **End-to-end type safety** from database to frontend via tRPC
|
||||
- 🧱 **Modern stack** with React, Hono/Elysia, and TanStack libraries
|
||||
- 📱 **Multi-platform** supporting web, mobile (Expo), and desktop applications
|
||||
- 🗃️ **Database flexibility** with SQLite (Turso) or PostgreSQL options
|
||||
- 🛠️ **ORM choice** between Drizzle or Prisma
|
||||
- 🔒 **Built-in authentication** with Better-Auth
|
||||
- 📱 **Optional PWA support** for mobile-friendly applications
|
||||
- 📱 **Optional PWA support** for installable web applications
|
||||
- 🖥️ **Desktop app capabilities** with Tauri integration
|
||||
- 📦 **Monorepo architecture** powered by Turborepo
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Create Better-T-Stack CLI
|
||||
|
||||
An interactive CLI tool to quickly scaffold full-stack TypeScript applications with a choice of modern backend frameworks (Hono or Elysia) and tRPC. The Better-T-Stack provides a type-safe development experience with the best tools from the TypeScript ecosystem.
|
||||
A CLI tool for scaffolding type-safe full-stack apps with Hono/Elysia backends, React web frontends, and Expo native apps, all connected through tRPC.
|
||||
|
||||
## Quick Start
|
||||
|
||||
@@ -23,6 +23,7 @@ Follow the prompts to configure your project or use the `-y` flag for defaults.
|
||||
|
||||
- **Monorepo**: Turborepo for optimized build system and workspace management
|
||||
- **Frontend**: React, TanStack Router, TanStack Query, Tailwind CSS with shadcn/ui components
|
||||
- **Native Apps**: Create React Native apps with Expo for iOS and Android
|
||||
- **Backend Frameworks**: Choose between Hono or Elysia
|
||||
- **API Layer**: End-to-end type safety with tRPC
|
||||
- **Runtime Options**: Choose between Bun or Node.js for your server
|
||||
@@ -70,6 +71,10 @@ Options:
|
||||
--hono Use Hono backend framework (default)
|
||||
--elysia Use Elysia backend framework
|
||||
--runtime <runtime> Specify runtime (bun or node)
|
||||
--web Include web frontend (default)
|
||||
--native Include Expo frontend
|
||||
--no-web Exclude web frontend
|
||||
--no-native Exclude Expo frontend
|
||||
-h, --help Display help
|
||||
```
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ export const PKG_ROOT = path.join(distPath, "../");
|
||||
|
||||
export const DEFAULT_CONFIG: ProjectConfig = {
|
||||
projectName: "my-better-t-app",
|
||||
frontend: ["web"],
|
||||
database: "sqlite",
|
||||
orm: "drizzle",
|
||||
auth: true,
|
||||
|
||||
@@ -87,7 +87,7 @@ async function setupPwa(projectDir: string) {
|
||||
await fs.copy(pwaTemplateDir, projectDir, { overwrite: true });
|
||||
}
|
||||
|
||||
const clientPackageDir = path.join(projectDir, "apps/client");
|
||||
const clientPackageDir = path.join(projectDir, "apps/web");
|
||||
|
||||
addPackageDependency({
|
||||
dependencies: ["vite-plugin-pwa"],
|
||||
|
||||
@@ -23,7 +23,7 @@ export async function setupAuth(
|
||||
}
|
||||
|
||||
const serverDir = path.join(projectDir, "apps/server");
|
||||
const clientDir = path.join(projectDir, "apps/client");
|
||||
const clientDir = path.join(projectDir, "apps/web");
|
||||
|
||||
try {
|
||||
addPackageDependency({
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
fixGitignoreFiles,
|
||||
setupAuthTemplate,
|
||||
setupBackendFramework,
|
||||
setupFrontendTemplates,
|
||||
setupOrmTemplate,
|
||||
} from "./template-manager";
|
||||
|
||||
@@ -29,6 +30,8 @@ export async function createProject(options: ProjectConfig): Promise<string> {
|
||||
await fs.ensureDir(projectDir);
|
||||
|
||||
await copyBaseTemplate(projectDir);
|
||||
await setupFrontendTemplates(projectDir, options.frontend);
|
||||
|
||||
await fixGitignoreFiles(projectDir);
|
||||
|
||||
await setupBackendFramework(projectDir, options.backendFramework);
|
||||
@@ -89,6 +92,7 @@ export async function createProject(options: ProjectConfig): Promise<string> {
|
||||
options.orm,
|
||||
options.addons,
|
||||
options.runtime,
|
||||
options.frontend,
|
||||
);
|
||||
|
||||
return projectDir;
|
||||
|
||||
@@ -57,7 +57,7 @@ Then, run the development server:
|
||||
${packageManagerRunCmd} dev
|
||||
\`\`\`
|
||||
|
||||
Open [http://localhost:3001](http://localhost:3001) in your browser to see the client application.
|
||||
Open [http://localhost:3001](http://localhost:3001) in your browser to see the web application.
|
||||
The API is running at [http://localhost:3000](http://localhost:3000).
|
||||
|
||||
## Project Structure
|
||||
@@ -65,7 +65,7 @@ The API is running at [http://localhost:3000](http://localhost:3000).
|
||||
\`\`\`
|
||||
${projectName}/
|
||||
├── apps/
|
||||
│ ├── client/ # Frontend application (React, TanStack Router)
|
||||
│ ├── web/ # Frontend application (React, TanStack Router)
|
||||
│ └── server/ # Backend API (Hono, tRPC)
|
||||
\`\`\`
|
||||
|
||||
@@ -173,9 +173,9 @@ function generateScriptsList(
|
||||
orm: ProjectOrm,
|
||||
auth: boolean,
|
||||
): string {
|
||||
let scripts = `- \`${packageManagerRunCmd} dev\`: Start both client and server in development mode
|
||||
- \`${packageManagerRunCmd} build\`: Build both client and server
|
||||
- \`${packageManagerRunCmd} dev:client\`: Start only the client
|
||||
let scripts = `- \`${packageManagerRunCmd} dev\`: Start both web and server in development mode
|
||||
- \`${packageManagerRunCmd} build\`: Build both web and server
|
||||
- \`${packageManagerRunCmd} dev:web\`: Start only the web application
|
||||
- \`${packageManagerRunCmd} dev:server\`: Start only the server
|
||||
- \`${packageManagerRunCmd} check-types\`: Check TypeScript types across all apps`;
|
||||
|
||||
|
||||
@@ -8,7 +8,6 @@ export async function setupEnvironmentVariables(
|
||||
options: ProjectConfig,
|
||||
): Promise<void> {
|
||||
const serverDir = path.join(projectDir, "apps/server");
|
||||
const clientDir = path.join(projectDir, "apps/client");
|
||||
|
||||
const envPath = path.join(serverDir, ".env");
|
||||
let envContent = "";
|
||||
@@ -49,16 +48,35 @@ export async function setupEnvironmentVariables(
|
||||
|
||||
await fs.writeFile(envPath, envContent.trim());
|
||||
|
||||
const clientEnvPath = path.join(clientDir, ".env");
|
||||
let clientEnvContent = "";
|
||||
if (options.frontend.includes("web")) {
|
||||
const clientDir = path.join(projectDir, "apps/web");
|
||||
const clientEnvPath = path.join(clientDir, ".env");
|
||||
let clientEnvContent = "";
|
||||
|
||||
if (await fs.pathExists(clientEnvPath)) {
|
||||
clientEnvContent = await fs.readFile(clientEnvPath, "utf8");
|
||||
if (await fs.pathExists(clientEnvPath)) {
|
||||
clientEnvContent = await fs.readFile(clientEnvPath, "utf8");
|
||||
}
|
||||
|
||||
if (!clientEnvContent.includes("VITE_SERVER_URL")) {
|
||||
clientEnvContent += "VITE_SERVER_URL=http://localhost:3000\n";
|
||||
}
|
||||
|
||||
await fs.writeFile(clientEnvPath, clientEnvContent.trim());
|
||||
}
|
||||
|
||||
if (!clientEnvContent.includes("VITE_SERVER_URL")) {
|
||||
clientEnvContent += "VITE_SERVER_URL=http://localhost:3000\n";
|
||||
}
|
||||
if (options.frontend.includes("native")) {
|
||||
const nativeDir = path.join(projectDir, "apps/native");
|
||||
const nativeEnvPath = path.join(nativeDir, ".env");
|
||||
let nativeEnvContent = "";
|
||||
|
||||
await fs.writeFile(clientEnvPath, clientEnvContent.trim());
|
||||
if (await fs.pathExists(nativeEnvPath)) {
|
||||
nativeEnvContent = await fs.readFile(nativeEnvPath, "utf8");
|
||||
}
|
||||
|
||||
if (!nativeEnvContent.includes("EXPO_PUBLIC_SERVER_URL")) {
|
||||
nativeEnvContent += "EXPO_PUBLIC_SERVER_URL=http://localhost:3000\n";
|
||||
}
|
||||
|
||||
await fs.writeFile(nativeEnvPath, nativeEnvContent.trim());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,8 +23,8 @@ async function setupTodoExample(
|
||||
): Promise<void> {
|
||||
const todoExampleDir = path.join(PKG_ROOT, "template/examples/todo");
|
||||
if (await fs.pathExists(todoExampleDir)) {
|
||||
const todoRouteDir = path.join(todoExampleDir, "apps/client/src/routes");
|
||||
const targetRouteDir = path.join(projectDir, "apps/client/src/routes");
|
||||
const todoRouteDir = path.join(todoExampleDir, "apps/web/src/routes");
|
||||
const targetRouteDir = path.join(projectDir, "apps/web/src/routes");
|
||||
await fs.copy(todoRouteDir, targetRouteDir, { overwrite: true });
|
||||
|
||||
if (orm !== "none") {
|
||||
@@ -55,7 +55,7 @@ async function updateHeaderWithTodoLink(
|
||||
): Promise<void> {
|
||||
const headerPath = path.join(
|
||||
projectDir,
|
||||
"apps/client/src/components/header.tsx",
|
||||
"apps/web/src/components/header.tsx",
|
||||
);
|
||||
|
||||
if (await fs.pathExists(headerPath)) {
|
||||
@@ -125,7 +125,7 @@ async function updateRouterIndex(projectDir: string): Promise<void> {
|
||||
}
|
||||
|
||||
async function addTodoButtonToHomepage(projectDir: string): Promise<void> {
|
||||
const indexPath = path.join(projectDir, "apps/client/src/routes/index.tsx");
|
||||
const indexPath = path.join(projectDir, "apps/web/src/routes/index.tsx");
|
||||
|
||||
if (await fs.pathExists(indexPath)) {
|
||||
let indexContent = await fs.readFile(indexPath, "utf8");
|
||||
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
PackageManager,
|
||||
ProjectAddons,
|
||||
ProjectDatabase,
|
||||
ProjectFrontend,
|
||||
ProjectOrm,
|
||||
Runtime,
|
||||
} from "../types";
|
||||
@@ -13,9 +14,10 @@ export function displayPostInstallInstructions(
|
||||
projectName: string,
|
||||
packageManager: PackageManager,
|
||||
depsInstalled: boolean,
|
||||
orm?: ProjectOrm,
|
||||
addons?: ProjectAddons[],
|
||||
runtime?: Runtime,
|
||||
orm: ProjectOrm,
|
||||
addons: ProjectAddons[],
|
||||
runtime: Runtime,
|
||||
frontends: ProjectFrontend[],
|
||||
) {
|
||||
const runCmd = packageManager === "npm" ? "npm run" : packageManager;
|
||||
const cdCmd = `cd ${projectName}`;
|
||||
@@ -32,15 +34,29 @@ export function displayPostInstallInstructions(
|
||||
const lintingInstructions = hasHuskyOrBiome
|
||||
? getLintingInstructions(runCmd)
|
||||
: "";
|
||||
const nativeInstructions = frontends?.includes("native")
|
||||
? getNativeInstructions()
|
||||
: "";
|
||||
|
||||
const hasWebFrontend = frontends?.includes("web");
|
||||
const hasNativeFrontend = frontends?.includes("native");
|
||||
const hasFrontend = hasWebFrontend || hasNativeFrontend;
|
||||
|
||||
log.info(`${pc.bold("Next steps:")}
|
||||
${pc.cyan("1.")} ${cdCmd}
|
||||
${!depsInstalled ? `${pc.cyan("2.")} ${packageManager} install\n` : ""}${pc.cyan(depsInstalled ? "2." : "3.")} ${runCmd} dev
|
||||
|
||||
${pc.bold("Your project will be available at:")}
|
||||
${pc.cyan("•")} Frontend: http://localhost:3001
|
||||
${pc.cyan("•")} API: http://localhost:3000
|
||||
${databaseInstructions ? `\n${databaseInstructions.trim()}` : ""}${tauriInstructions ? `\n${tauriInstructions.trim()}` : ""}${lintingInstructions ? `\n${lintingInstructions.trim()}` : ""}`);
|
||||
${
|
||||
hasFrontend
|
||||
? `${hasWebFrontend ? `${pc.cyan("•")} Frontend: http://localhost:3001\n` : ""}`
|
||||
: `${pc.yellow("NOTE:")} You are creating a backend-only app (no frontend selected)\n`
|
||||
}${pc.cyan("•")} API: http://localhost:3000
|
||||
${nativeInstructions ? `\n${nativeInstructions.trim()}` : ""}${databaseInstructions ? `\n${databaseInstructions.trim()}` : ""}${tauriInstructions ? `\n${tauriInstructions.trim()}` : ""}${lintingInstructions ? `\n${lintingInstructions.trim()}` : ""}`);
|
||||
}
|
||||
|
||||
function getNativeInstructions(): string {
|
||||
return `${pc.yellow("NOTE:")} If the Expo app cannot connect to the server, update the EXPO_PUBLIC_SERVER_URL in apps/native/.env to use your local IP address instead of localhost:\n${pc.dim("EXPO_PUBLIC_SERVER_URL=http://192.168.0.103:3000")}\n`;
|
||||
}
|
||||
|
||||
function getLintingInstructions(runCmd?: string): string {
|
||||
@@ -95,5 +111,5 @@ function getDatabaseInstructions(
|
||||
}
|
||||
|
||||
function getTauriInstructions(runCmd?: string): string {
|
||||
return `${pc.bold("Desktop app with Tauri:")}\n${pc.cyan("•")} Start desktop app: ${pc.dim(`cd apps/client && ${runCmd} desktop:dev`)}\n${pc.cyan("•")} Build desktop app: ${pc.dim(`cd apps/client && ${runCmd} desktop:build`)}\n${pc.yellow("NOTE:")} Tauri requires Rust and platform-specific dependencies. See: ${pc.dim("https://v2.tauri.app/start/prerequisites/")}\n\n`;
|
||||
return `${pc.bold("Desktop app with Tauri:")}\n${pc.cyan("•")} Start desktop app: ${pc.dim(`cd apps/web && ${runCmd} desktop:dev`)}\n${pc.cyan("•")} Build desktop app: ${pc.dim(`cd apps/web && ${runCmd} desktop:build`)}\n${pc.yellow("NOTE:")} Tauri requires Rust and platform-specific dependencies. See: ${pc.dim("https://v2.tauri.app/start/prerequisites/")}\n\n`;
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ export async function setupTauri(
|
||||
packageManager: PackageManager,
|
||||
): Promise<void> {
|
||||
const s = spinner();
|
||||
const clientPackageDir = path.join(projectDir, "apps/client");
|
||||
const clientPackageDir = path.join(projectDir, "apps/web");
|
||||
|
||||
try {
|
||||
s.start("Setting up Tauri desktop app support...");
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import path from "node:path";
|
||||
import fs from "fs-extra";
|
||||
import { PKG_ROOT } from "../constants";
|
||||
import type { BackendFramework, ProjectDatabase, ProjectOrm } from "../types";
|
||||
import type {
|
||||
BackendFramework,
|
||||
ProjectDatabase,
|
||||
ProjectFrontend,
|
||||
ProjectOrm,
|
||||
} from "../types";
|
||||
|
||||
export async function copyBaseTemplate(projectDir: string): Promise<void> {
|
||||
const templateDir = path.join(PKG_ROOT, "template/base");
|
||||
@@ -11,6 +16,30 @@ export async function copyBaseTemplate(projectDir: string): Promise<void> {
|
||||
await fs.copy(templateDir, projectDir);
|
||||
}
|
||||
|
||||
export async function setupFrontendTemplates(
|
||||
projectDir: string,
|
||||
frontends: ProjectFrontend[],
|
||||
): Promise<void> {
|
||||
if (!frontends.includes("web")) {
|
||||
const webDir = path.join(projectDir, "apps/web");
|
||||
if (await fs.pathExists(webDir)) {
|
||||
await fs.remove(webDir);
|
||||
}
|
||||
}
|
||||
|
||||
if (!frontends.includes("native")) {
|
||||
const nativeDir = path.join(projectDir, "apps/native");
|
||||
if (await fs.pathExists(nativeDir)) {
|
||||
await fs.remove(nativeDir);
|
||||
}
|
||||
} else {
|
||||
await fs.writeFile(
|
||||
path.join(projectDir, ".npmrc"),
|
||||
"node-linker=hoisted\n",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function setupBackendFramework(
|
||||
projectDir: string,
|
||||
framework: BackendFramework,
|
||||
@@ -67,8 +96,8 @@ export async function setupAuthTemplate(
|
||||
|
||||
const authTemplateDir = path.join(PKG_ROOT, "template/with-auth");
|
||||
if (await fs.pathExists(authTemplateDir)) {
|
||||
const clientAuthDir = path.join(authTemplateDir, "apps/client");
|
||||
const projectClientDir = path.join(projectDir, "apps/client");
|
||||
const clientAuthDir = path.join(authTemplateDir, "apps/web");
|
||||
const projectClientDir = path.join(projectDir, "apps/web");
|
||||
await fs.copy(clientAuthDir, projectClientDir, { overwrite: true });
|
||||
|
||||
const serverAuthDir = path.join(authTemplateDir, "apps/server/src");
|
||||
@@ -118,7 +147,8 @@ export async function setupAuthTemplate(
|
||||
export async function fixGitignoreFiles(projectDir: string): Promise<void> {
|
||||
const gitignorePaths = [
|
||||
path.join(projectDir, "_gitignore"),
|
||||
path.join(projectDir, "apps/client/_gitignore"),
|
||||
path.join(projectDir, "apps/web/_gitignore"),
|
||||
path.join(projectDir, "apps/native/_gitignore"),
|
||||
path.join(projectDir, "apps/server/_gitignore"),
|
||||
];
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
ProjectAddons,
|
||||
ProjectConfig,
|
||||
ProjectExamples,
|
||||
ProjectFrontend,
|
||||
Runtime,
|
||||
} from "./types";
|
||||
import { displayConfig } from "./utils/display-config";
|
||||
@@ -59,6 +60,10 @@ async function main() {
|
||||
.option("--hono", "Use Hono backend framework")
|
||||
.option("--elysia", "Use Elysia backend framework")
|
||||
.option("--runtime <runtime>", "Specify runtime (bun or node)")
|
||||
.option("--web", "Include web frontend")
|
||||
.option("--native", "Include Expo frontend")
|
||||
.option("--no-web", "Exclude web frontend")
|
||||
.option("--no-native", "Exclude Expo frontend")
|
||||
.parse();
|
||||
|
||||
const s = spinner();
|
||||
@@ -115,6 +120,16 @@ async function main() {
|
||||
.filter((e) => e === "todo") as ProjectExamples[])
|
||||
: [],
|
||||
}),
|
||||
...((options.web !== undefined || options.native !== undefined) && {
|
||||
frontend: [
|
||||
...(options.web === false ? [] : options.web === true ? ["web"] : []),
|
||||
...(options.native === false
|
||||
? []
|
||||
: options.native === true
|
||||
? ["native"]
|
||||
: []),
|
||||
] as ProjectFrontend[],
|
||||
}),
|
||||
};
|
||||
|
||||
if (!options.yes && Object.keys(flagConfig).length > 0) {
|
||||
|
||||
@@ -1,12 +1,26 @@
|
||||
import { cancel, confirm, isCancel } from "@clack/prompts";
|
||||
import { cancel, confirm, isCancel, log } from "@clack/prompts";
|
||||
import pc from "picocolors";
|
||||
import { DEFAULT_CONFIG } from "../constants";
|
||||
import type { ProjectFrontend } from "../types";
|
||||
|
||||
export async function getAuthChoice(
|
||||
auth: boolean | undefined,
|
||||
hasDatabase: boolean,
|
||||
frontends?: ProjectFrontend[],
|
||||
): Promise<boolean> {
|
||||
if (!hasDatabase) return false;
|
||||
|
||||
const hasNative = frontends?.includes("native");
|
||||
const hasWeb = frontends?.includes("web");
|
||||
|
||||
if (hasNative) {
|
||||
log.warn(
|
||||
pc.yellow("Note: Authentication is not yet available with native"),
|
||||
);
|
||||
}
|
||||
|
||||
if (!hasWeb) return false;
|
||||
|
||||
if (auth !== undefined) return auth;
|
||||
|
||||
const response = await confirm({
|
||||
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
ProjectConfig,
|
||||
ProjectDatabase,
|
||||
ProjectExamples,
|
||||
ProjectFrontend,
|
||||
ProjectOrm,
|
||||
Runtime,
|
||||
} from "../types";
|
||||
@@ -15,6 +16,7 @@ import { getAuthChoice } from "./auth";
|
||||
import { getBackendFrameworkChoice } from "./backend-framework";
|
||||
import { getDatabaseChoice } from "./database";
|
||||
import { getExamplesChoice } from "./examples";
|
||||
import { getFrontendChoice } from "./frontend-option";
|
||||
import { getGitChoice } from "./git";
|
||||
import { getNoInstallChoice } from "./install";
|
||||
import { getORMChoice } from "./orm";
|
||||
@@ -36,6 +38,7 @@ type PromptGroupResults = {
|
||||
turso: boolean;
|
||||
backendFramework: BackendFramework;
|
||||
runtime: Runtime;
|
||||
frontend: ProjectFrontend[];
|
||||
};
|
||||
|
||||
export async function gatherConfig(
|
||||
@@ -46,13 +49,18 @@ export async function gatherConfig(
|
||||
projectName: async () => {
|
||||
return getProjectName(flags.projectName);
|
||||
},
|
||||
frontend: () => getFrontendChoice(flags.frontend),
|
||||
backendFramework: () => getBackendFrameworkChoice(flags.backendFramework),
|
||||
runtime: () => getRuntimeChoice(flags.runtime),
|
||||
database: () => getDatabaseChoice(flags.database),
|
||||
orm: ({ results }) =>
|
||||
getORMChoice(flags.orm, results.database !== "none"),
|
||||
auth: ({ results }) =>
|
||||
getAuthChoice(flags.auth, results.database !== "none"),
|
||||
getAuthChoice(
|
||||
flags.auth,
|
||||
results.database !== "none",
|
||||
results.frontend,
|
||||
),
|
||||
turso: ({ results }) =>
|
||||
results.database === "sqlite" && results.orm !== "prisma"
|
||||
? getTursoSetupChoice(flags.turso)
|
||||
@@ -74,6 +82,7 @@ export async function gatherConfig(
|
||||
|
||||
return {
|
||||
projectName: result.projectName,
|
||||
frontend: result.frontend,
|
||||
database: result.database,
|
||||
orm: result.orm,
|
||||
auth: result.auth,
|
||||
|
||||
35
apps/cli/src/prompts/frontend-option.ts
Normal file
35
apps/cli/src/prompts/frontend-option.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { cancel, isCancel, multiselect } from "@clack/prompts";
|
||||
import pc from "picocolors";
|
||||
import { DEFAULT_CONFIG } from "../constants";
|
||||
import type { ProjectFrontend } from "../types";
|
||||
|
||||
export async function getFrontendChoice(
|
||||
frontendOptions?: ProjectFrontend[],
|
||||
): Promise<ProjectFrontend[]> {
|
||||
if (frontendOptions !== undefined) return frontendOptions;
|
||||
|
||||
const response = await multiselect<ProjectFrontend>({
|
||||
message: "Which frontend applications would you like to create?",
|
||||
options: [
|
||||
{
|
||||
value: "web",
|
||||
label: "Web App",
|
||||
hint: "React + TanStack Router web application",
|
||||
},
|
||||
{
|
||||
value: "native",
|
||||
label: "Native App",
|
||||
hint: "React Native + Expo application",
|
||||
},
|
||||
],
|
||||
initialValues: DEFAULT_CONFIG.frontend,
|
||||
required: false,
|
||||
});
|
||||
|
||||
if (isCancel(response)) {
|
||||
cancel(pc.red("Operation cancelled"));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
@@ -21,7 +21,7 @@ export async function getORMChoice(
|
||||
{
|
||||
value: "prisma",
|
||||
label: "Prisma",
|
||||
hint: "Powerful, feature-rich ORM with schema migrations",
|
||||
hint: "Powerful, feature-rich ORM",
|
||||
},
|
||||
],
|
||||
initialValue: DEFAULT_CONFIG.orm,
|
||||
|
||||
@@ -5,6 +5,7 @@ export type ProjectAddons = "pwa" | "biome" | "tauri" | "husky";
|
||||
export type BackendFramework = "hono" | "elysia";
|
||||
export type Runtime = "node" | "bun";
|
||||
export type ProjectExamples = "todo";
|
||||
export type ProjectFrontend = "web" | "native";
|
||||
|
||||
export interface ProjectConfig {
|
||||
projectName: string;
|
||||
@@ -19,4 +20,5 @@ export interface ProjectConfig {
|
||||
packageManager: PackageManager;
|
||||
noInstall?: boolean;
|
||||
turso?: boolean;
|
||||
frontend: ProjectFrontend[];
|
||||
}
|
||||
|
||||
@@ -35,6 +35,20 @@ export function generateReproducibleCommand(config: ProjectConfig): string {
|
||||
flags.push(`--runtime ${config.runtime}`);
|
||||
}
|
||||
|
||||
if (config.frontend) {
|
||||
if (config.frontend.includes("web")) {
|
||||
flags.push("--web");
|
||||
} else {
|
||||
flags.push("--no-web");
|
||||
}
|
||||
|
||||
if (config.frontend.includes("native")) {
|
||||
flags.push("--native");
|
||||
} else {
|
||||
flags.push("--no-native");
|
||||
}
|
||||
}
|
||||
|
||||
if (config.addons.length > 0) {
|
||||
for (const addon of config.addons) {
|
||||
flags.push(`--${addon}`);
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
import {
|
||||
QueryCache,
|
||||
QueryClient,
|
||||
QueryClientProvider,
|
||||
} from "@tanstack/react-query";
|
||||
import { RouterProvider, createRouter } from "@tanstack/react-router";
|
||||
import { httpBatchLink } from "@trpc/client";
|
||||
import { createTRPCQueryUtils } from "@trpc/react-query";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { toast } from "sonner";
|
||||
import Loader from "./components/loader";
|
||||
import { routeTree } from "./routeTree.gen";
|
||||
import { trpc } from "./utils/trpc";
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
queryCache: new QueryCache({
|
||||
onError: (error) => {
|
||||
toast.error(error.message, {
|
||||
action: {
|
||||
label: "retry",
|
||||
onClick: () => {
|
||||
queryClient.invalidateQueries();
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const trpcClient = trpc.createClient({
|
||||
links: [
|
||||
httpBatchLink({
|
||||
url: `${import.meta.env.VITE_SERVER_URL}/trpc`,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
export const trpcQueryUtils = createTRPCQueryUtils({
|
||||
queryClient,
|
||||
client: trpcClient,
|
||||
});
|
||||
|
||||
const router = createRouter({
|
||||
routeTree,
|
||||
defaultPreload: "intent",
|
||||
context: { trpcQueryUtils },
|
||||
defaultPendingComponent: () => <Loader />,
|
||||
Wrap: function WrapComponent({ children }) {
|
||||
return (
|
||||
<trpc.Provider client={trpcClient} queryClient={queryClient}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
</trpc.Provider>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
// Register things for typesafety
|
||||
declare module "@tanstack/react-router" {
|
||||
interface Register {
|
||||
router: typeof router;
|
||||
}
|
||||
}
|
||||
|
||||
const rootElement = document.getElementById("app");
|
||||
if (!rootElement) throw new Error("Root element not found");
|
||||
|
||||
if (!rootElement.innerHTML) {
|
||||
const root = ReactDOM.createRoot(rootElement);
|
||||
root.render(<RouterProvider router={router} />);
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
import { createTRPCReact } from "@trpc/react-query";
|
||||
import type { AppRouter } from "../../../server/src/routers";
|
||||
|
||||
export const trpc = createTRPCReact<AppRouter>();
|
||||
24
apps/cli/template/base/apps/native/_gitignore
Normal file
24
apps/cli/template/base/apps/native/_gitignore
Normal file
@@ -0,0 +1,24 @@
|
||||
node_modules/
|
||||
.expo/
|
||||
dist/
|
||||
npm-debug.*
|
||||
*.jks
|
||||
*.p8
|
||||
*.p12
|
||||
*.key
|
||||
*.mobileprovision
|
||||
*.orig.*
|
||||
web-build/
|
||||
# expo router
|
||||
expo-env.d.ts
|
||||
|
||||
|
||||
|
||||
ios
|
||||
android
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
|
||||
# Temporary files created by Metro to check the health of the file watcher
|
||||
.metro-health-check*
|
||||
2
apps/cli/template/base/apps/native/app-env.d.ts
vendored
Normal file
2
apps/cli/template/base/apps/native/app-env.d.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
// @ts-ignore
|
||||
/// <reference types="nativewind/types" />
|
||||
38
apps/cli/template/base/apps/native/app.json
Normal file
38
apps/cli/template/base/apps/native/app.json
Normal file
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"expo": {
|
||||
"name": "my-better-t-app",
|
||||
"slug": "my-better-t-app",
|
||||
"version": "1.0.0",
|
||||
"scheme": "my-better-t-app",
|
||||
"web": {
|
||||
"bundler": "metro",
|
||||
"output": "static",
|
||||
"favicon": "./assets/favicon.png"
|
||||
},
|
||||
"plugins": ["expo-router"],
|
||||
"experiments": {
|
||||
"typedRoutes": true,
|
||||
"tsconfigPaths": true
|
||||
},
|
||||
"orientation": "portrait",
|
||||
"icon": "./assets/icon.png",
|
||||
"userInterfaceStyle": "light",
|
||||
"splash": {
|
||||
"image": "./assets/splash.png",
|
||||
"resizeMode": "contain",
|
||||
"backgroundColor": "#ffffff"
|
||||
},
|
||||
"assetBundlePatterns": ["**/*"],
|
||||
"ios": {
|
||||
"supportsTablet": true,
|
||||
"bundleIdentifier": "com.amanvarshney01.mybettertapp"
|
||||
},
|
||||
"android": {
|
||||
"adaptiveIcon": {
|
||||
"foregroundImage": "./assets/adaptive-icon.png",
|
||||
"backgroundColor": "#ffffff"
|
||||
},
|
||||
"package": "com.amanvarshney01.mybettertapp"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Tabs } from "expo-router";
|
||||
|
||||
import { TabBarIcon } from "@/components/tabbar-icon";
|
||||
|
||||
export default function TabLayout() {
|
||||
return (
|
||||
<Tabs
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
tabBarActiveTintColor: "black",
|
||||
}}
|
||||
>
|
||||
<Tabs.Screen
|
||||
name="index"
|
||||
options={{
|
||||
title: "Tab One",
|
||||
tabBarIcon: ({ color }) => <TabBarIcon name="code" color={color} />,
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="two"
|
||||
options={{
|
||||
title: "Tab Two",
|
||||
tabBarIcon: ({ color }) => <TabBarIcon name="code" color={color} />,
|
||||
}}
|
||||
/>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { View, Text } from "react-native";
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<View>
|
||||
<Text>Hello, World!</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Stack } from "expo-router";
|
||||
import { View, Text } from "react-native";
|
||||
|
||||
import { Container } from "@/components/container";
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<>
|
||||
<Container>
|
||||
<View>
|
||||
<Text>Tab Two</Text>
|
||||
</View>
|
||||
</Container>
|
||||
</>
|
||||
);
|
||||
}
|
||||
39
apps/cli/template/base/apps/native/app/(drawer)/_layout.tsx
Normal file
39
apps/cli/template/base/apps/native/app/(drawer)/_layout.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import { Ionicons, MaterialIcons } from "@expo/vector-icons";
|
||||
import { Link } from "expo-router";
|
||||
import { Drawer } from "expo-router/drawer";
|
||||
|
||||
import { HeaderButton } from "@/components/header-button";
|
||||
|
||||
const DrawerLayout = () => {
|
||||
return (
|
||||
<Drawer>
|
||||
<Drawer.Screen
|
||||
name="index"
|
||||
options={{
|
||||
headerTitle: "Home",
|
||||
drawerLabel: "Home",
|
||||
drawerIcon: ({ size, color }) => (
|
||||
<Ionicons name="home-outline" size={size} color={color} />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Drawer.Screen
|
||||
name="(tabs)"
|
||||
options={{
|
||||
headerTitle: "Tabs",
|
||||
drawerLabel: "Tabs",
|
||||
drawerIcon: ({ size, color }) => (
|
||||
<MaterialIcons name="border-bottom" size={size} color={color} />
|
||||
),
|
||||
headerRight: () => (
|
||||
<Link href="/modal" asChild>
|
||||
<HeaderButton />
|
||||
</Link>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</Drawer>
|
||||
);
|
||||
};
|
||||
|
||||
export default DrawerLayout;
|
||||
36
apps/cli/template/base/apps/native/app/(drawer)/index.tsx
Normal file
36
apps/cli/template/base/apps/native/app/(drawer)/index.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { View, Text, ScrollView } from "react-native";
|
||||
import { Container } from "@/components/container";
|
||||
import { trpc } from "@/utils/trpc";
|
||||
|
||||
export default function Home() {
|
||||
const healthCheck = useQuery(trpc.healthCheck.queryOptions());
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<ScrollView className="py-4 flex-1">
|
||||
<Text className="font-mono text-foreground text-2xl font-bold mb-6">
|
||||
BETTER T STACK
|
||||
</Text>
|
||||
|
||||
<View className="rounded-lg border border-foreground p-4">
|
||||
<Text className="mb-2 font-medium text-foreground">API Status</Text>
|
||||
<View className="flex-row items-center gap-2">
|
||||
<View
|
||||
className={`h-2.5 w-2.5 rounded-full ${
|
||||
healthCheck.data ? "bg-green-500" : "bg-red-500"
|
||||
}`}
|
||||
/>
|
||||
<Text className="text-sm text-foreground">
|
||||
{healthCheck.isLoading
|
||||
? "Checking..."
|
||||
: healthCheck.data
|
||||
? "Connected"
|
||||
: "Disconnected"}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
46
apps/cli/template/base/apps/native/app/+html.tsx
Normal file
46
apps/cli/template/base/apps/native/app/+html.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { ScrollViewStyleReset } from 'expo-router/html';
|
||||
|
||||
// This file is web-only and used to configure the root HTML for every
|
||||
// web page during static rendering.
|
||||
// The contents of this function only run in Node.js environments and
|
||||
// do not have access to the DOM or browser APIs.
|
||||
export default function Root({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charSet="utf-8" />
|
||||
<meta httpEquiv="X-UA-Compatible" content="IE=edge" />
|
||||
|
||||
{/*
|
||||
This viewport disables scaling which makes the mobile website act more like a native app.
|
||||
However this does reduce built-in accessibility. If you want to enable scaling, use this instead:
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
*/}
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width,initial-scale=1,minimum-scale=1,maximum-scale=1.00001,viewport-fit=cover"
|
||||
/>
|
||||
{/*
|
||||
Disable body scrolling on web. This makes ScrollView components work closer to how they do on native.
|
||||
However, body scrolling is often nice to have for mobile web. If you want to enable it, remove this line.
|
||||
*/}
|
||||
<ScrollViewStyleReset />
|
||||
|
||||
{/* Using raw CSS styles as an escape-hatch to ensure the background color never flickers in dark-mode. */}
|
||||
<style dangerouslySetInnerHTML={{ __html: responsiveBackground }} />
|
||||
{/* Add any additional <head> elements that you want globally available on web... */}
|
||||
</head>
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
const responsiveBackground = `
|
||||
body {
|
||||
background-color: #fff;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
body {
|
||||
background-color: #000;
|
||||
}
|
||||
}`;
|
||||
18
apps/cli/template/base/apps/native/app/+not-found.tsx
Normal file
18
apps/cli/template/base/apps/native/app/+not-found.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { Link, Stack } from 'expo-router';
|
||||
import { Text } from 'react-native';
|
||||
|
||||
import { Container } from '@/components/container';
|
||||
|
||||
export default function NotFoundScreen() {
|
||||
return (
|
||||
<>
|
||||
<Stack.Screen options={{ title: 'Oops!' }} />
|
||||
<Container>
|
||||
<Text className="text-xl font-bold">This screen doesn't exist.</Text>
|
||||
<Link href="/" className="mt-4 pt-4">
|
||||
<Text className="text-base text-[#2e78b7]">Go to home screen!</Text>
|
||||
</Link>
|
||||
</Container>
|
||||
</>
|
||||
);
|
||||
}
|
||||
76
apps/cli/template/base/apps/native/app/_layout.tsx
Normal file
76
apps/cli/template/base/apps/native/app/_layout.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import { Stack } from "expo-router";
|
||||
import {
|
||||
DarkTheme,
|
||||
DefaultTheme,
|
||||
Theme,
|
||||
ThemeProvider,
|
||||
} from "@react-navigation/native";
|
||||
import { StatusBar } from "expo-status-bar";
|
||||
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
||||
import "../global.css";
|
||||
import { queryClient } from "@/utils/trpc";
|
||||
import { NAV_THEME } from "@/lib/constants";
|
||||
import React, { useRef } from "react";
|
||||
import { useColorScheme } from "@/lib/use-color-scheme";
|
||||
import { Platform } from "react-native";
|
||||
import { setAndroidNavigationBar } from "@/lib/android-navigation-bar";
|
||||
|
||||
const LIGHT_THEME: Theme = {
|
||||
...DefaultTheme,
|
||||
colors: NAV_THEME.light,
|
||||
};
|
||||
const DARK_THEME: Theme = {
|
||||
...DarkTheme,
|
||||
colors: NAV_THEME.dark,
|
||||
};
|
||||
|
||||
export const unstable_settings = {
|
||||
// Ensure that reloading on `/modal` keeps a back button present.
|
||||
initialRouteName: "(drawer)",
|
||||
};
|
||||
|
||||
export default function RootLayout() {
|
||||
const hasMounted = useRef(false);
|
||||
const { colorScheme, isDarkColorScheme } = useColorScheme();
|
||||
const [isColorSchemeLoaded, setIsColorSchemeLoaded] = React.useState(false);
|
||||
|
||||
useIsomorphicLayoutEffect(() => {
|
||||
if (hasMounted.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (Platform.OS === "web") {
|
||||
// Adds the background color to the html element to prevent white background on overscroll.
|
||||
document.documentElement.classList.add("bg-background");
|
||||
}
|
||||
setAndroidNavigationBar(colorScheme);
|
||||
setIsColorSchemeLoaded(true);
|
||||
hasMounted.current = true;
|
||||
}, []);
|
||||
|
||||
if (!isColorSchemeLoaded) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ThemeProvider value={isDarkColorScheme ? DARK_THEME : LIGHT_THEME}>
|
||||
<StatusBar style={isDarkColorScheme ? "light" : "dark"} />
|
||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||
<Stack>
|
||||
<Stack.Screen name="(drawer)" options={{ headerShown: false }} />
|
||||
<Stack.Screen
|
||||
name="modal"
|
||||
options={{ title: "Modal", presentation: "modal" }}
|
||||
/>
|
||||
</Stack>
|
||||
</GestureHandlerRootView>
|
||||
</ThemeProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const useIsomorphicLayoutEffect =
|
||||
Platform.OS === "web" && typeof window === "undefined"
|
||||
? React.useEffect
|
||||
: React.useLayoutEffect;
|
||||
15
apps/cli/template/base/apps/native/app/modal.tsx
Normal file
15
apps/cli/template/base/apps/native/app/modal.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { Text, View } from "react-native";
|
||||
|
||||
import { Container } from "@/components/container";
|
||||
|
||||
export default function Modal() {
|
||||
return (
|
||||
<>
|
||||
<Container>
|
||||
<View>
|
||||
<Text>HI MODAL</Text>
|
||||
</View>
|
||||
</Container>
|
||||
</>
|
||||
);
|
||||
}
|
||||
BIN
apps/cli/template/base/apps/native/assets/adaptive-icon.png
Normal file
BIN
apps/cli/template/base/apps/native/assets/adaptive-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
BIN
apps/cli/template/base/apps/native/assets/favicon.png
Normal file
BIN
apps/cli/template/base/apps/native/assets/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
BIN
apps/cli/template/base/apps/native/assets/icon.png
Normal file
BIN
apps/cli/template/base/apps/native/assets/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
BIN
apps/cli/template/base/apps/native/assets/splash.png
Normal file
BIN
apps/cli/template/base/apps/native/assets/splash.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 46 KiB |
12
apps/cli/template/base/apps/native/babel.config.js
Normal file
12
apps/cli/template/base/apps/native/babel.config.js
Normal file
@@ -0,0 +1,12 @@
|
||||
module.exports = function (api) {
|
||||
api.cache(true);
|
||||
const plugins = [];
|
||||
|
||||
plugins.push('react-native-reanimated/plugin');
|
||||
|
||||
return {
|
||||
presets: [['babel-preset-expo', { jsxImportSource: 'nativewind' }], 'nativewind/babel'],
|
||||
|
||||
plugins,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
import { SafeAreaView } from "react-native";
|
||||
|
||||
export const Container = ({ children }: { children: React.ReactNode }) => {
|
||||
return (
|
||||
<SafeAreaView className="flex flex-1 p-4 bg-background">
|
||||
{children}
|
||||
</SafeAreaView>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
import FontAwesome from '@expo/vector-icons/FontAwesome';
|
||||
import { forwardRef } from 'react';
|
||||
import { Pressable, StyleSheet } from 'react-native';
|
||||
|
||||
export const HeaderButton = forwardRef<typeof Pressable, { onPress?: () => void }>(
|
||||
({ onPress }, ref) => {
|
||||
return (
|
||||
<Pressable onPress={onPress}>
|
||||
{({ pressed }) => (
|
||||
<FontAwesome
|
||||
name="info-circle"
|
||||
size={25}
|
||||
color="gray"
|
||||
style={[
|
||||
styles.headerRight,
|
||||
{
|
||||
opacity: pressed ? 0.5 : 1,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export const styles = StyleSheet.create({
|
||||
headerRight: {
|
||||
marginRight: 15,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
import FontAwesome from '@expo/vector-icons/FontAwesome';
|
||||
import { StyleSheet } from 'react-native';
|
||||
|
||||
export const TabBarIcon = (props: {
|
||||
name: React.ComponentProps<typeof FontAwesome>['name'];
|
||||
color: string;
|
||||
}) => {
|
||||
return <FontAwesome size={28} style={styles.tabBarIcon} {...props} />;
|
||||
};
|
||||
|
||||
export const styles = StyleSheet.create({
|
||||
tabBarIcon: {
|
||||
marginBottom: -3,
|
||||
},
|
||||
});
|
||||
25
apps/cli/template/base/apps/native/global.css
Normal file
25
apps/cli/template/base/apps/native/global.css
Normal file
@@ -0,0 +1,25 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 240 10% 3.9%;
|
||||
--primary: 240 5.9% 10%;
|
||||
--primary-foreground: 0 0% 98%;
|
||||
--secondary: 240 4.8% 95.9%;
|
||||
--secondary-foreground: 240 5.9% 10%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
}
|
||||
|
||||
.dark:root {
|
||||
--background: 240 10% 3.9%;
|
||||
--foreground: 0 0% 98%;
|
||||
--primary: 0 0% 98%;
|
||||
--primary-foreground: 240 5.9% 10%;
|
||||
--secondary: 240 3.7% 15.9%;
|
||||
--secondary-foreground: 0 0% 98%;
|
||||
--destructive: 0 72% 51%;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import * as NavigationBar from "expo-navigation-bar";
|
||||
import { Platform } from "react-native";
|
||||
import { NAV_THEME } from "@/lib/constants";
|
||||
|
||||
export async function setAndroidNavigationBar(theme: "light" | "dark") {
|
||||
if (Platform.OS !== "android") return;
|
||||
await NavigationBar.setButtonStyleAsync(theme === "dark" ? "light" : "dark");
|
||||
await NavigationBar.setBackgroundColorAsync(
|
||||
theme === "dark" ? NAV_THEME.dark.background : NAV_THEME.light.background,
|
||||
);
|
||||
}
|
||||
18
apps/cli/template/base/apps/native/lib/constants.ts
Normal file
18
apps/cli/template/base/apps/native/lib/constants.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
export const NAV_THEME = {
|
||||
light: {
|
||||
background: "hsl(0 0% 100%)", // background
|
||||
border: "hsl(240 5.9% 90%)", // border
|
||||
card: "hsl(0 0% 100%)", // card
|
||||
notification: "hsl(0 84.2% 60.2%)", // destructive
|
||||
primary: "hsl(240 5.9% 10%)", // primary
|
||||
text: "hsl(240 10% 3.9%)", // foreground
|
||||
},
|
||||
dark: {
|
||||
background: "hsl(240 10% 3.9%)", // background
|
||||
border: "hsl(240 3.7% 15.9%)", // border
|
||||
card: "hsl(240 10% 3.9%)", // card
|
||||
notification: "hsl(0 72% 51%)", // destructive
|
||||
primary: "hsl(0 0% 98%)", // primary
|
||||
text: "hsl(0 0% 98%)", // foreground
|
||||
},
|
||||
};
|
||||
12
apps/cli/template/base/apps/native/lib/use-color-scheme.ts
Normal file
12
apps/cli/template/base/apps/native/lib/use-color-scheme.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { useColorScheme as useNativewindColorScheme } from "nativewind";
|
||||
|
||||
export function useColorScheme() {
|
||||
const { colorScheme, setColorScheme, toggleColorScheme } =
|
||||
useNativewindColorScheme();
|
||||
return {
|
||||
colorScheme: colorScheme ?? "dark",
|
||||
isDarkColorScheme: colorScheme === "dark",
|
||||
setColorScheme,
|
||||
toggleColorScheme,
|
||||
};
|
||||
}
|
||||
57
apps/cli/template/base/apps/native/metro.config.js
Normal file
57
apps/cli/template/base/apps/native/metro.config.js
Normal file
@@ -0,0 +1,57 @@
|
||||
// Learn more https://docs.expo.io/guides/customizing-metro
|
||||
const { getDefaultConfig } = require("expo/metro-config");
|
||||
const { FileStore } = require("metro-cache");
|
||||
const { withNativeWind } = require("nativewind/metro");
|
||||
const path = require("path");
|
||||
|
||||
const config = withTurborepoManagedCache(
|
||||
withMonorepoPaths(
|
||||
withNativeWind(getDefaultConfig(__dirname), {
|
||||
input: "./global.css",
|
||||
configPath: "./tailwind.config.js",
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
config.resolver.unstable_enablePackageExports = true;
|
||||
|
||||
module.exports = config;
|
||||
|
||||
/**
|
||||
* Add the monorepo paths to the Metro config.
|
||||
* This allows Metro to resolve modules from the monorepo.
|
||||
*
|
||||
* @see https://docs.expo.dev/guides/monorepos/#modify-the-metro-config
|
||||
* @param {import('expo/metro-config').MetroConfig} config
|
||||
* @returns {import('expo/metro-config').MetroConfig}
|
||||
*/
|
||||
function withMonorepoPaths(config) {
|
||||
const projectRoot = __dirname;
|
||||
const workspaceRoot = path.resolve(projectRoot, "../..");
|
||||
|
||||
// #1 - Watch all files in the monorepo
|
||||
config.watchFolders = [workspaceRoot];
|
||||
|
||||
// #2 - Resolve modules within the project's `node_modules` first, then all monorepo modules
|
||||
config.resolver.nodeModulesPaths = [
|
||||
path.resolve(projectRoot, "node_modules"),
|
||||
path.resolve(workspaceRoot, "node_modules"),
|
||||
];
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Move the Metro cache to the `.cache/metro` folder.
|
||||
* If you have any environment variables, you can configure Turborepo to invalidate it when needed.
|
||||
*
|
||||
* @see https://turbo.build/repo/docs/reference/configuration#env
|
||||
* @param {import('expo/metro-config').MetroConfig} config
|
||||
* @returns {import('expo/metro-config').MetroConfig}
|
||||
*/
|
||||
function withTurborepoManagedCache(config) {
|
||||
config.cacheStores = [
|
||||
new FileStore({ root: path.join(__dirname, ".cache/metro") }),
|
||||
];
|
||||
return config;
|
||||
}
|
||||
47
apps/cli/template/base/apps/native/package.json
Normal file
47
apps/cli/template/base/apps/native/package.json
Normal file
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"name": "native",
|
||||
"version": "1.0.0",
|
||||
"main": "expo-router/entry",
|
||||
"scripts": {
|
||||
"dev": "expo start",
|
||||
"android": "expo run:android",
|
||||
"ios": "expo run:ios",
|
||||
"prebuild": "expo prebuild",
|
||||
"web": "expo start --web"
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/vector-icons": "^14.0.4",
|
||||
"@react-navigation/bottom-tabs": "^7.2.0",
|
||||
"@react-navigation/drawer": "^7.1.1",
|
||||
"@react-navigation/native": "^7.0.14",
|
||||
"@tanstack/react-form": "^1.0.5",
|
||||
"@tanstack/react-query": "^5.69.2",
|
||||
"@trpc/client": "^11.0.0",
|
||||
"@trpc/server": "^11.0.0",
|
||||
"@trpc/tanstack-react-query": "^11.0.0",
|
||||
"expo": "^52.0.41",
|
||||
"expo-constants": "~17.0.8",
|
||||
"expo-linking": "~7.0.5",
|
||||
"expo-navigation-bar": "~4.0.8",
|
||||
"expo-router": "~4.0.19",
|
||||
"expo-status-bar": "~2.0.1",
|
||||
"expo-system-ui": "~4.0.8",
|
||||
"expo-web-browser": "~14.0.2",
|
||||
"nativewind": "^4.1.23",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
"react-native": "0.76.7",
|
||||
"react-native-gesture-handler": "~2.20.2",
|
||||
"react-native-reanimated": "3.16.2",
|
||||
"react-native-safe-area-context": "4.12.0",
|
||||
"react-native-screens": "~4.4.0",
|
||||
"react-native-web": "~0.19.13"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.26.10",
|
||||
"@types/react": "~18.3.12",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "~5.8.2"
|
||||
},
|
||||
"private": true
|
||||
}
|
||||
32
apps/cli/template/base/apps/native/tailwind.config.js
Normal file
32
apps/cli/template/base/apps/native/tailwind.config.js
Normal file
@@ -0,0 +1,32 @@
|
||||
const { hairlineWidth } = require("nativewind/theme");
|
||||
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
darkMode: "class",
|
||||
content: ["./app/**/*.{js,ts,tsx}", "./components/**/*.{js,ts,tsx}"],
|
||||
|
||||
presets: [require("nativewind/preset")],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
background: "hsl(var(--background))",
|
||||
foreground: "hsl(var(--foreground))",
|
||||
primary: {
|
||||
DEFAULT: "hsl(var(--primary))",
|
||||
foreground: "hsl(var(--primary-foreground))",
|
||||
},
|
||||
secondary: {
|
||||
DEFAULT: "hsl(var(--secondary))",
|
||||
foreground: "hsl(var(--secondary-foreground))",
|
||||
},
|
||||
destructive: {
|
||||
DEFAULT: "hsl(var(--destructive))",
|
||||
},
|
||||
},
|
||||
borderWidth: {
|
||||
hairline: hairlineWidth(),
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
18
apps/cli/template/base/apps/native/tsconfig.json
Normal file
18
apps/cli/template/base/apps/native/tsconfig.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"extends": "expo/tsconfig.base",
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".expo/types/**/*.ts",
|
||||
"expo-env.d.ts",
|
||||
"nativewind-env.d.ts"
|
||||
]
|
||||
}
|
||||
19
apps/cli/template/base/apps/native/utils/trpc.ts
Normal file
19
apps/cli/template/base/apps/native/utils/trpc.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import type { AppRouter } from "../../server/src/routers";
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
import { createTRPCClient, httpBatchLink } from "@trpc/client";
|
||||
import { createTRPCOptionsProxy } from "@trpc/tanstack-react-query";
|
||||
|
||||
export const queryClient = new QueryClient();
|
||||
|
||||
const trpcClient = createTRPCClient<AppRouter>({
|
||||
links: [
|
||||
httpBatchLink({
|
||||
url: `${process.env.EXPO_PUBLIC_SERVER_URL}/trpc`,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
export const trpc = createTRPCOptionsProxy<AppRouter>({
|
||||
client: trpcClient,
|
||||
queryClient,
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "client",
|
||||
"name": "web",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
33
apps/cli/template/base/apps/web/src/main.tsx
Normal file
33
apps/cli/template/base/apps/web/src/main.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import { RouterProvider, createRouter } from "@tanstack/react-router";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import Loader from "./components/loader";
|
||||
import { routeTree } from "./routeTree.gen";
|
||||
import { queryClient, trpcClient } from "./utils/trpc";
|
||||
|
||||
const router = createRouter({
|
||||
routeTree,
|
||||
defaultPreload: "intent",
|
||||
defaultPendingComponent: () => <Loader />,
|
||||
context: { trpcClient },
|
||||
Wrap: function WrapComponent({ children }) {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
// Register things for typesafety
|
||||
declare module "@tanstack/react-router" {
|
||||
interface Register {
|
||||
router: typeof router;
|
||||
}
|
||||
}
|
||||
|
||||
const rootElement = document.getElementById("app");
|
||||
if (!rootElement) throw new Error("Root element not found");
|
||||
|
||||
if (!rootElement.innerHTML) {
|
||||
const root = ReactDOM.createRoot(rootElement);
|
||||
root.render(<RouterProvider router={router} />);
|
||||
}
|
||||
@@ -2,19 +2,19 @@ import Header from "@/components/header";
|
||||
import Loader from "@/components/loader";
|
||||
import { ThemeProvider } from "@/components/theme-provider";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import { trpcQueryUtils } from "@/main";
|
||||
import type { trpcClient } from "@/utils/trpc";
|
||||
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
|
||||
import {
|
||||
HeadContent,
|
||||
Outlet,
|
||||
createRootRouteWithContext,
|
||||
useRouterState,
|
||||
HeadContent,
|
||||
} from "@tanstack/react-router";
|
||||
import { TanStackRouterDevtools } from "@tanstack/react-router-devtools";
|
||||
import "../index.css";
|
||||
|
||||
export interface RouterAppContext {
|
||||
trpcQueryUtils: typeof trpcQueryUtils;
|
||||
trpcClient: typeof trpcClient;
|
||||
}
|
||||
|
||||
export const Route = createRootRouteWithContext<RouterAppContext>()({
|
||||
@@ -1,7 +1,8 @@
|
||||
import { trpc } from "@/utils/trpc";
|
||||
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||
import { ArrowRight } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { trpc } from "@/utils/trpc";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Link, createFileRoute } from "@tanstack/react-router";
|
||||
import { ArrowRight } from "lucide-react";
|
||||
|
||||
export const Route = createFileRoute("/")({
|
||||
component: HomeComponent,
|
||||
@@ -24,7 +25,7 @@ const TITLE_TEXT = `
|
||||
`;
|
||||
|
||||
function HomeComponent() {
|
||||
const healthCheck = trpc.healthCheck.useQuery();
|
||||
const healthCheck = useQuery(trpc.healthCheck.queryOptions());
|
||||
|
||||
return (
|
||||
<div className="container mx-auto max-w-3xl px-4 py-2">
|
||||
@@ -33,9 +34,9 @@ function HomeComponent() {
|
||||
<section className="rounded-lg border p-4">
|
||||
<h2 className="mb-2 font-medium">API Status</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className={`h-2 w-2 rounded-full ${healthCheck.data ? "bg-green-500" : "bg-red-500"}`}
|
||||
/>
|
||||
<div
|
||||
className={`h-2 w-2 rounded-full ${healthCheck.data ? "bg-green-500" : "bg-red-500"}`}
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{healthCheck.isLoading
|
||||
? "Checking..."
|
||||
33
apps/cli/template/base/apps/web/src/utils/trpc.ts
Normal file
33
apps/cli/template/base/apps/web/src/utils/trpc.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import type { AppRouter } from "../../../server/src/routers";
|
||||
import { QueryCache, QueryClient } from "@tanstack/react-query";
|
||||
import { createTRPCClient, httpBatchLink } from "@trpc/client";
|
||||
import { createTRPCOptionsProxy } from "@trpc/tanstack-react-query";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export const queryClient = new QueryClient({
|
||||
queryCache: new QueryCache({
|
||||
onError: (error) => {
|
||||
toast.error(error.message, {
|
||||
action: {
|
||||
label: "retry",
|
||||
onClick: () => {
|
||||
queryClient.invalidateQueries();
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
export const trpcClient = createTRPCClient<AppRouter>({
|
||||
links: [
|
||||
httpBatchLink({
|
||||
url: `${import.meta.env.VITE_SERVER_URL}/trpc`,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
export const trpc = createTRPCOptionsProxy<AppRouter>({
|
||||
client: trpcClient,
|
||||
queryClient,
|
||||
});
|
||||
@@ -6,7 +6,8 @@
|
||||
"dev": "turbo dev",
|
||||
"build": "turbo build",
|
||||
"check-types": "turbo check-types",
|
||||
"dev:client": "turbo -F client dev",
|
||||
"dev:native": "turbo -F native dev",
|
||||
"dev:web": "turbo -F web dev",
|
||||
"dev:server": "turbo -F server dev",
|
||||
"db:push": "turbo -F server db:push",
|
||||
"db:studio": "turbo -F server db:studio"
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
import {
|
||||
QueryCache,
|
||||
QueryClient,
|
||||
QueryClientProvider,
|
||||
} from "@tanstack/react-query";
|
||||
import { RouterProvider, createRouter } from "@tanstack/react-router";
|
||||
import { httpBatchLink } from "@trpc/client";
|
||||
import { createTRPCQueryUtils } from "@trpc/react-query";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { toast } from "sonner";
|
||||
import Loader from "./components/loader";
|
||||
import { routeTree } from "./routeTree.gen";
|
||||
import { trpc } from "./utils/trpc";
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
queryCache: new QueryCache({
|
||||
onError: (error) => {
|
||||
toast.error(error.message, {
|
||||
action: {
|
||||
label: "retry",
|
||||
onClick: () => {
|
||||
queryClient.invalidateQueries();
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const trpcClient = trpc.createClient({
|
||||
links: [
|
||||
httpBatchLink({
|
||||
url: `${import.meta.env.VITE_SERVER_URL}/trpc`,
|
||||
fetch(url, options) {
|
||||
return fetch(url, {
|
||||
...options,
|
||||
credentials: "include",
|
||||
});
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
export const trpcQueryUtils = createTRPCQueryUtils({
|
||||
queryClient,
|
||||
client: trpcClient,
|
||||
});
|
||||
|
||||
const router = createRouter({
|
||||
routeTree,
|
||||
defaultPreload: "intent",
|
||||
context: { trpcQueryUtils },
|
||||
defaultPendingComponent: () => <Loader />,
|
||||
Wrap: function WrapComponent({ children }) {
|
||||
return (
|
||||
<trpc.Provider client={trpcClient} queryClient={queryClient}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
</trpc.Provider>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
// Register things for typesafety
|
||||
declare module "@tanstack/react-router" {
|
||||
interface Register {
|
||||
router: typeof router;
|
||||
}
|
||||
}
|
||||
|
||||
const rootElement = document.getElementById("app");
|
||||
if (!rootElement) throw new Error("Root element not found");
|
||||
|
||||
if (!rootElement.innerHTML) {
|
||||
const root = ReactDOM.createRoot(rootElement);
|
||||
root.render(<RouterProvider router={router} />);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { trpc } from "@/utils/trpc";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { useEffect } from "react";
|
||||
|
||||
@@ -12,7 +13,7 @@ function RouteComponent() {
|
||||
|
||||
const navigate = Route.useNavigate();
|
||||
|
||||
const privateData = trpc.privateData.useQuery();
|
||||
const privateData = useQuery(trpc.privateData.queryOptions());
|
||||
|
||||
useEffect(() => {
|
||||
if (!session && !isPending) {
|
||||
39
apps/cli/template/with-auth/apps/web/src/utils/trpc.ts
Normal file
39
apps/cli/template/with-auth/apps/web/src/utils/trpc.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import type { AppRouter } from "../../../server/src/routers";
|
||||
import { QueryCache, QueryClient } from "@tanstack/react-query";
|
||||
import { createTRPCClient, httpBatchLink } from "@trpc/client";
|
||||
import { createTRPCOptionsProxy } from "@trpc/tanstack-react-query";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export const queryClient = new QueryClient({
|
||||
queryCache: new QueryCache({
|
||||
onError: (error) => {
|
||||
toast.error(error.message, {
|
||||
action: {
|
||||
label: "retry",
|
||||
onClick: () => {
|
||||
queryClient.invalidateQueries();
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
export const trpcClient = createTRPCClient<AppRouter>({
|
||||
links: [
|
||||
httpBatchLink({
|
||||
url: `${import.meta.env.VITE_SERVER_URL}/trpc`,
|
||||
fetch(url, options) {
|
||||
return fetch(url, {
|
||||
...options,
|
||||
credentials: "include",
|
||||
});
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
export const trpc = createTRPCOptionsProxy<AppRouter>({
|
||||
client: trpcClient,
|
||||
queryClient,
|
||||
});
|
||||
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 13 KiB |
Reference in New Issue
Block a user