add unistyles
5
.changeset/weak-carrots-return.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"create-better-t-stack": minor
|
||||||
|
---
|
||||||
|
|
||||||
|
add expo with unistyles
|
||||||
@@ -23,21 +23,21 @@ Follow the prompts to configure your project or use the `--yes` flag for default
|
|||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
| Category | Options |
|
| Category | Options |
|
||||||
|----------|---------|
|
| ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| **TypeScript** | End-to-end type safety across all parts of your application |
|
| **TypeScript** | End-to-end type safety across all parts of your application |
|
||||||
| **Frontend** | • React with TanStack Router<br>• React with React Router<br>• React with TanStack Start (SSR)<br>• Next.js<br>• SvelteKit<br>• Nuxt (Vue)<br>• SolidJS<br>• React Native with Expo<br>• None |
|
| **Frontend** | • React with TanStack Router<br>• React with React Router<br>• React with TanStack Start (SSR)<br>• Next.js<br>• SvelteKit<br>• Nuxt (Vue)<br>• SolidJS<br>• React Native with NativeWind (via Expo)<br>• React Native with Unistyles (via Expo)<br>• None |
|
||||||
| **Backend** | • Hono<br>• Express<br>• Elysia<br>• Next.js API routes<br>• Convex |
|
| **Backend** | • Hono<br>• Express<br>• Elysia<br>• Next.js API routes<br>• Convex<br>• None |
|
||||||
| **API Layer** | • tRPC (type-safe APIs)<br>• oRPC (OpenAPI-compatible type-safe APIs) |
|
| **API Layer** | • tRPC (type-safe APIs)<br>• oRPC (OpenAPI-compatible type-safe APIs)<br>• None |
|
||||||
| **Runtime** | • Bun<br>• Node.js |
|
| **Runtime** | • Bun<br>• Node.js |
|
||||||
| **Database** | • SQLite<br>• PostgreSQL<br>• MySQL<br>• MongoDB<br>• None |
|
| **Database** | • SQLite<br>• PostgreSQL<br>• MySQL<br>• MongoDB<br>• None |
|
||||||
| **ORM** | • Drizzle (TypeScript-first)<br>• Prisma (feature-rich)<br>• Mongoose (for MongoDB)<br>• None |
|
| **ORM** | • Drizzle (TypeScript-first)<br>• Prisma (feature-rich)<br>• Mongoose (for MongoDB)<br>• None |
|
||||||
| **Database Setup** | • Turso (SQLite)<br>• Neon (PostgreSQL)<br>• Prisma Postgres (via Prisma Accelerate)<br>• MongoDB Atlas<br>• None (manual setup) |
|
| **Database Setup** | • Turso (SQLite)<br>• Neon (PostgreSQL)<br>• Prisma Postgres (via Prisma Accelerate)<br>• MongoDB Atlas<br>• None (manual setup) |
|
||||||
| **Authentication** | Better-Auth (email/password, with more options coming soon) |
|
| **Authentication** | Better-Auth (email/password, with more options coming soon) |
|
||||||
| **Styling** | Tailwind CSS with shadcn/ui components |
|
| **Styling** | Tailwind CSS with shadcn/ui components |
|
||||||
| **Addons** | • PWA support<br>• Tauri (desktop applications)<br>• Starlight (documentation site)<br>• Biome (linting and formatting)<br>• Husky (Git hooks)<br>• Turborepo (optimized builds) |
|
| **Addons** | • PWA support<br>• Tauri (desktop applications)<br>• Starlight (documentation site)<br>• Biome (linting and formatting)<br>• Husky (Git hooks)<br>• Turborepo (optimized builds) |
|
||||||
| **Examples** | • Todo app<br>• AI Chat interface (using Vercel AI SDK) |
|
| **Examples** | • Todo app<br>• AI Chat interface (using Vercel AI SDK) |
|
||||||
| **Developer Experience** | • Automatic Git initialization<br>• Package manager choice (npm, pnpm, bun)<br>• Automatic dependency installation |
|
| **Developer Experience** | • Automatic Git initialization<br>• Package manager choice (npm, pnpm, bun)<br>• Automatic dependency installation |
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
@@ -51,7 +51,7 @@ Options:
|
|||||||
--orm <type> ORM type (none, drizzle, prisma, mongoose)
|
--orm <type> ORM type (none, drizzle, prisma, mongoose)
|
||||||
--auth Include authentication
|
--auth Include authentication
|
||||||
--no-auth Exclude authentication
|
--no-auth Exclude authentication
|
||||||
--frontend <types...> Frontend types (tanstack-router, react-router, tanstack-start, next, nuxt, svelte, solid, native, none)
|
--frontend <types...> Frontend types (tanstack-router, react-router, tanstack-start, next, nuxt, svelte, solid, native-nativewind, native-unistyles, none)
|
||||||
--addons <types...> Additional addons (pwa, tauri, starlight, biome, husky, turborepo, none)
|
--addons <types...> Additional addons (pwa, tauri, starlight, biome, husky, turborepo, none)
|
||||||
--examples <types...> Examples to include (todo, ai, none)
|
--examples <types...> Examples to include (todo, ai, none)
|
||||||
--git Initialize git repository
|
--git Initialize git repository
|
||||||
@@ -119,6 +119,7 @@ npx create-better-t-stack my-app --addons starlight
|
|||||||
## Compatibility Notes
|
## Compatibility Notes
|
||||||
|
|
||||||
- **Convex backend**: Automatically disables authentication, database, ORM, and API options
|
- **Convex backend**: Automatically disables authentication, database, ORM, and API options
|
||||||
|
- **Backend 'none'**: If selected, this option will force related options like API, ORM, database, authentication, and runtime to 'none'. Examples will also be disabled (set to none/empty).
|
||||||
- **SvelteKit, Nuxt, and SolidJS** frontends are only compatible with oRPC API layer
|
- **SvelteKit, Nuxt, and SolidJS** frontends are only compatible with oRPC API layer
|
||||||
- **PWA support** requires React with TanStack Router, React Router, or SolidJS
|
- **PWA support** requires React with TanStack Router, React Router, or SolidJS
|
||||||
- **Tauri desktop app** requires React (TanStack Router/React Router), Nuxt, SvelteKit, or SolidJS
|
- **Tauri desktop app** requires React (TanStack Router/React Router), Nuxt, SvelteKit, or SolidJS
|
||||||
|
|||||||
@@ -125,7 +125,8 @@ export async function setupApi(config: ProjectConfig): Promise<void> {
|
|||||||
"tanstack-router",
|
"tanstack-router",
|
||||||
"tanstack-start",
|
"tanstack-start",
|
||||||
"next",
|
"next",
|
||||||
"native",
|
"native-nativewind",
|
||||||
|
"native-unistyles",
|
||||||
];
|
];
|
||||||
const needsSolidQuery = frontend.includes("solid");
|
const needsSolidQuery = frontend.includes("solid");
|
||||||
const needsReactQuery = frontend.some((f) => reactBasedFrontends.includes(f));
|
const needsReactQuery = frontend.some((f) => reactBasedFrontends.includes(f));
|
||||||
@@ -137,9 +138,14 @@ export async function setupApi(config: ProjectConfig): Promise<void> {
|
|||||||
];
|
];
|
||||||
|
|
||||||
const hasReactWeb = frontend.some(
|
const hasReactWeb = frontend.some(
|
||||||
(f) => f !== "native" && reactBasedFrontends.includes(f),
|
(f) =>
|
||||||
|
f !== "native-nativewind" &&
|
||||||
|
f !== "native-unistyles" &&
|
||||||
|
reactBasedFrontends.includes(f),
|
||||||
);
|
);
|
||||||
const hasNative = frontend.includes("native");
|
const hasNative =
|
||||||
|
frontend.includes("native-nativewind") ||
|
||||||
|
frontend.includes("native-unistyles");
|
||||||
|
|
||||||
if (hasReactWeb && webDirExists) {
|
if (hasReactWeb && webDirExists) {
|
||||||
const webPkgJsonPath = path.join(webDir, "package.json");
|
const webPkgJsonPath = path.join(webDir, "package.json");
|
||||||
|
|||||||
@@ -45,7 +45,11 @@ export async function setupAuth(config: ProjectConfig): Promise<void> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (frontend.includes("native") && nativeDirExists) {
|
if (
|
||||||
|
(frontend.includes("native-nativewind") ||
|
||||||
|
frontend.includes("native-unistyles")) &&
|
||||||
|
nativeDirExists
|
||||||
|
) {
|
||||||
await addPackageDependency({
|
await addPackageDependency({
|
||||||
dependencies: ["better-auth", "@better-auth/expo"],
|
dependencies: ["better-auth", "@better-auth/expo"],
|
||||||
projectDir: nativeDir,
|
projectDir: nativeDir,
|
||||||
|
|||||||
@@ -39,7 +39,9 @@ function generateReadmeContent(options: ProjectConfig): string {
|
|||||||
const isConvex = backend === "convex";
|
const isConvex = backend === "convex";
|
||||||
const hasReactRouter = frontend.includes("react-router");
|
const hasReactRouter = frontend.includes("react-router");
|
||||||
const hasTanstackRouter = frontend.includes("tanstack-router");
|
const hasTanstackRouter = frontend.includes("tanstack-router");
|
||||||
const hasNative = frontend.includes("native");
|
const hasNative =
|
||||||
|
frontend.includes("native-nativewind") ||
|
||||||
|
frontend.includes("native-unistyles");
|
||||||
const hasNext = frontend.includes("next");
|
const hasNext = frontend.includes("next");
|
||||||
const hasTanstackStart = frontend.includes("tanstack-start");
|
const hasTanstackStart = frontend.includes("tanstack-start");
|
||||||
const hasSvelte = frontend.includes("svelte");
|
const hasSvelte = frontend.includes("svelte");
|
||||||
@@ -78,7 +80,16 @@ This project was created with [Better-T-Stack](https://github.com/AmanVarshney01
|
|||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
${generateFeaturesList(database, auth, addons, orm, runtime, frontend, backend, api)}
|
${generateFeaturesList(
|
||||||
|
database,
|
||||||
|
auth,
|
||||||
|
addons,
|
||||||
|
orm,
|
||||||
|
runtime,
|
||||||
|
frontend,
|
||||||
|
backend,
|
||||||
|
api,
|
||||||
|
)}
|
||||||
|
|
||||||
## Getting Started
|
## Getting Started
|
||||||
|
|
||||||
@@ -207,7 +218,9 @@ function generateFeaturesList(
|
|||||||
const isConvex = backend === "convex";
|
const isConvex = backend === "convex";
|
||||||
const hasTanstackRouter = frontend.includes("tanstack-router");
|
const hasTanstackRouter = frontend.includes("tanstack-router");
|
||||||
const hasReactRouter = frontend.includes("react-router");
|
const hasReactRouter = frontend.includes("react-router");
|
||||||
const hasNative = frontend.includes("native");
|
const hasNative =
|
||||||
|
frontend.includes("native-nativewind") ||
|
||||||
|
frontend.includes("native-unistyles");
|
||||||
const hasNext = frontend.includes("next");
|
const hasNext = frontend.includes("next");
|
||||||
const hasTanstackStart = frontend.includes("tanstack-start");
|
const hasTanstackStart = frontend.includes("tanstack-start");
|
||||||
const hasSvelte = frontend.includes("svelte");
|
const hasSvelte = frontend.includes("svelte");
|
||||||
|
|||||||
@@ -118,7 +118,10 @@ export async function setupEnvironmentVariables(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (frontend.includes("native")) {
|
if (
|
||||||
|
frontend.includes("native-nativewind") ||
|
||||||
|
frontend.includes("native-unistyles")
|
||||||
|
) {
|
||||||
const nativeDir = path.join(projectDir, "apps/native");
|
const nativeDir = path.join(projectDir, "apps/native");
|
||||||
if (await fs.pathExists(nativeDir)) {
|
if (await fs.pathExists(nativeDir)) {
|
||||||
let envVarName = "EXPO_PUBLIC_SERVER_URL";
|
let envVarName = "EXPO_PUBLIC_SERVER_URL";
|
||||||
|
|||||||
@@ -1,11 +1,6 @@
|
|||||||
import { consola } from "consola";
|
import { consola } from "consola";
|
||||||
import pc from "picocolors";
|
import pc from "picocolors";
|
||||||
import type {
|
import type { ProjectDatabase, ProjectOrm, ProjectRuntime } from "../types";
|
||||||
ProjectBackend,
|
|
||||||
ProjectDatabase,
|
|
||||||
ProjectOrm,
|
|
||||||
ProjectRuntime,
|
|
||||||
} from "../types";
|
|
||||||
import { getPackageExecutionCommand } from "../utils/get-package-execution-command";
|
import { getPackageExecutionCommand } from "../utils/get-package-execution-command";
|
||||||
|
|
||||||
import type { ProjectConfig } from "../types";
|
import type { ProjectConfig } from "../types";
|
||||||
@@ -43,9 +38,11 @@ export function displayPostInstallInstructions(
|
|||||||
const lintingInstructions = hasHuskyOrBiome
|
const lintingInstructions = hasHuskyOrBiome
|
||||||
? getLintingInstructions(runCmd)
|
? getLintingInstructions(runCmd)
|
||||||
: "";
|
: "";
|
||||||
const nativeInstructions = frontend?.includes("native")
|
const nativeInstructions =
|
||||||
? getNativeInstructions(isConvex)
|
frontend?.includes("native-nativewind") ||
|
||||||
: "";
|
frontend?.includes("native-unistyles")
|
||||||
|
? getNativeInstructions(isConvex)
|
||||||
|
: "";
|
||||||
const pwaInstructions =
|
const pwaInstructions =
|
||||||
addons?.includes("pwa") &&
|
addons?.includes("pwa") &&
|
||||||
(frontend?.includes("react-router") ||
|
(frontend?.includes("react-router") ||
|
||||||
@@ -67,7 +64,9 @@ export function displayPostInstallInstructions(
|
|||||||
"solid",
|
"solid",
|
||||||
].includes(f),
|
].includes(f),
|
||||||
);
|
);
|
||||||
const hasNative = frontend?.includes("native");
|
const hasNative =
|
||||||
|
frontend?.includes("native-nativewind") ||
|
||||||
|
frontend?.includes("native-unistyles");
|
||||||
|
|
||||||
const bunWebNativeWarning =
|
const bunWebNativeWarning =
|
||||||
packageManager === "bun" && hasNative && hasWeb
|
packageManager === "bun" && hasNative && hasWeb
|
||||||
@@ -90,7 +89,9 @@ export function displayPostInstallInstructions(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isConvex) {
|
if (isConvex) {
|
||||||
output += `${pc.cyan(`${stepCounter++}.`)} ${runCmd} dev:setup ${pc.dim("(this will guide you through Convex project setup)")}\n`;
|
output += `${pc.cyan(`${stepCounter++}.`)} ${runCmd} dev:setup ${pc.dim(
|
||||||
|
"(this will guide you through Convex project setup)",
|
||||||
|
)}\n`;
|
||||||
output += `${pc.cyan(`${stepCounter++}.`)} ${runCmd} dev\n\n`;
|
output += `${pc.cyan(`${stepCounter++}.`)} ${runCmd} dev\n\n`;
|
||||||
} else {
|
} else {
|
||||||
output += `${pc.cyan(`${stepCounter++}.`)} ${runCmd} dev\n\n`;
|
output += `${pc.cyan(`${stepCounter++}.`)} ${runCmd} dev\n\n`;
|
||||||
@@ -101,7 +102,9 @@ export function displayPostInstallInstructions(
|
|||||||
if (hasWeb) {
|
if (hasWeb) {
|
||||||
output += `${pc.cyan("•")} Frontend: http://localhost:${webPort}\n`;
|
output += `${pc.cyan("•")} Frontend: http://localhost:${webPort}\n`;
|
||||||
} else if (!hasNative && !addons?.includes("starlight")) {
|
} else if (!hasNative && !addons?.includes("starlight")) {
|
||||||
output += `${pc.yellow("NOTE:")} You are creating a backend-only app (no frontend selected)\n`;
|
output += `${pc.yellow(
|
||||||
|
"NOTE:",
|
||||||
|
)} You are creating a backend-only app (no frontend selected)\n`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isConvex) {
|
if (!isConvex) {
|
||||||
@@ -122,8 +125,12 @@ export function displayPostInstallInstructions(
|
|||||||
if (noOrmWarning) output += `\n${noOrmWarning.trim()}\n`;
|
if (noOrmWarning) output += `\n${noOrmWarning.trim()}\n`;
|
||||||
if (bunWebNativeWarning) output += `\n${bunWebNativeWarning.trim()}\n`;
|
if (bunWebNativeWarning) output += `\n${bunWebNativeWarning.trim()}\n`;
|
||||||
|
|
||||||
output += `\n${pc.bold("Update all dependencies:\n")}${pc.cyan(tazeCommand)}\n\n`;
|
output += `\n${pc.bold("Update all dependencies:\n")}${pc.cyan(
|
||||||
output += `${pc.bold("Like Better-T Stack?")} Please consider giving us a star on GitHub:\n`;
|
tazeCommand,
|
||||||
|
)}\n\n`;
|
||||||
|
output += `${pc.bold(
|
||||||
|
"Like Better-T Stack?",
|
||||||
|
)} Please consider giving us a star on GitHub:\n`;
|
||||||
output += pc.cyan("https://github.com/AmanVarshney01/create-better-t-stack");
|
output += pc.cyan("https://github.com/AmanVarshney01/create-better-t-stack");
|
||||||
|
|
||||||
consola.box(output);
|
consola.box(output);
|
||||||
@@ -183,7 +190,9 @@ function getDatabaseInstructions(
|
|||||||
instructions.push(`${pc.cyan("•")} Database UI: ${`${runCmd} db:studio`}`);
|
instructions.push(`${pc.cyan("•")} Database UI: ${`${runCmd} db:studio`}`);
|
||||||
if (database === "sqlite") {
|
if (database === "sqlite") {
|
||||||
instructions.push(
|
instructions.push(
|
||||||
`${pc.cyan("•")} Start local DB (if needed): ${`cd apps/server && ${runCmd} db:local`}`,
|
`${pc.cyan(
|
||||||
|
"•",
|
||||||
|
)} Start local DB (if needed): ${`cd apps/server && ${runCmd} db:local`}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else if (orm === "none") {
|
} else if (orm === "none") {
|
||||||
|
|||||||
@@ -70,7 +70,9 @@ export async function setupFrontendTemplates(
|
|||||||
const hasNuxtWeb = context.frontend.includes("nuxt");
|
const hasNuxtWeb = context.frontend.includes("nuxt");
|
||||||
const hasSvelteWeb = context.frontend.includes("svelte");
|
const hasSvelteWeb = context.frontend.includes("svelte");
|
||||||
const hasSolidWeb = context.frontend.includes("solid");
|
const hasSolidWeb = context.frontend.includes("solid");
|
||||||
const hasNative = context.frontend.includes("native");
|
const hasNativeWind = context.frontend.includes("native-nativewind");
|
||||||
|
const hasUnistyles = context.frontend.includes("native-unistyles");
|
||||||
|
const hasNative = hasNativeWind || hasUnistyles;
|
||||||
const isConvex = context.backend === "convex";
|
const isConvex = context.backend === "convex";
|
||||||
|
|
||||||
if (hasReactWeb || hasNuxtWeb || hasSvelteWeb || hasSolidWeb) {
|
if (hasReactWeb || hasNuxtWeb || hasSvelteWeb || hasSolidWeb) {
|
||||||
@@ -181,16 +183,45 @@ export async function setupFrontendTemplates(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasNative) {
|
if (hasNativeWind || hasUnistyles) {
|
||||||
const nativeAppDir = path.join(projectDir, "apps/native");
|
const nativeAppDir = path.join(projectDir, "apps/native");
|
||||||
await fs.ensureDir(nativeAppDir);
|
await fs.ensureDir(nativeAppDir);
|
||||||
|
|
||||||
const nativeBaseDir = path.join(PKG_ROOT, "templates/frontend/native");
|
const nativeBaseCommonDir = path.join(
|
||||||
if (await fs.pathExists(nativeBaseDir)) {
|
PKG_ROOT,
|
||||||
await processAndCopyFiles("**/*", nativeBaseDir, nativeAppDir, context);
|
"templates/frontend/native/native-base",
|
||||||
|
);
|
||||||
|
if (await fs.pathExists(nativeBaseCommonDir)) {
|
||||||
|
await processAndCopyFiles(
|
||||||
|
"**/*",
|
||||||
|
nativeBaseCommonDir,
|
||||||
|
nativeAppDir,
|
||||||
|
context,
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let nativeFrameworkPath = "";
|
||||||
|
if (hasNativeWind) {
|
||||||
|
nativeFrameworkPath = "nativewind";
|
||||||
|
} else if (hasUnistyles) {
|
||||||
|
nativeFrameworkPath = "unistyles";
|
||||||
|
}
|
||||||
|
|
||||||
|
const nativeSpecificDir = path.join(
|
||||||
|
PKG_ROOT,
|
||||||
|
`templates/frontend/native/${nativeFrameworkPath}`,
|
||||||
|
);
|
||||||
|
if (await fs.pathExists(nativeSpecificDir)) {
|
||||||
|
await processAndCopyFiles(
|
||||||
|
"**/*",
|
||||||
|
nativeSpecificDir,
|
||||||
|
nativeAppDir,
|
||||||
|
context,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (!isConvex && (context.api === "trpc" || context.api === "orpc")) {
|
if (!isConvex && (context.api === "trpc" || context.api === "orpc")) {
|
||||||
const apiNativeSrcDir = path.join(
|
const apiNativeSrcDir = path.join(
|
||||||
PKG_ROOT,
|
PKG_ROOT,
|
||||||
@@ -203,7 +234,6 @@ export async function setupFrontendTemplates(
|
|||||||
nativeAppDir,
|
nativeAppDir,
|
||||||
context,
|
context,
|
||||||
);
|
);
|
||||||
} else {
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -345,7 +375,9 @@ export async function setupAuthTemplate(
|
|||||||
const hasNuxtWeb = context.frontend.includes("nuxt");
|
const hasNuxtWeb = context.frontend.includes("nuxt");
|
||||||
const hasSvelteWeb = context.frontend.includes("svelte");
|
const hasSvelteWeb = context.frontend.includes("svelte");
|
||||||
const hasSolidWeb = context.frontend.includes("solid");
|
const hasSolidWeb = context.frontend.includes("solid");
|
||||||
const hasNative = context.frontend.includes("native");
|
const hasNativeWind = context.frontend.includes("native-nativewind");
|
||||||
|
const hasUnistyles = context.frontend.includes("native-unistyles");
|
||||||
|
const hasNative = hasNativeWind || hasUnistyles;
|
||||||
|
|
||||||
if (serverAppDirExists) {
|
if (serverAppDirExists) {
|
||||||
const authServerBaseSrc = path.join(PKG_ROOT, "templates/auth/server/base");
|
const authServerBaseSrc = path.join(PKG_ROOT, "templates/auth/server/base");
|
||||||
@@ -475,10 +507,39 @@ export async function setupAuthTemplate(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (hasNative && nativeAppDirExists) {
|
if (hasNative && nativeAppDirExists) {
|
||||||
const authNativeSrc = path.join(PKG_ROOT, "templates/auth/native");
|
const authNativeBaseSrc = path.join(
|
||||||
if (await fs.pathExists(authNativeSrc)) {
|
PKG_ROOT,
|
||||||
await processAndCopyFiles("**/*", authNativeSrc, nativeAppDir, context);
|
"templates/auth/native/native-base",
|
||||||
} else {
|
);
|
||||||
|
if (await fs.pathExists(authNativeBaseSrc)) {
|
||||||
|
await processAndCopyFiles(
|
||||||
|
"**/*",
|
||||||
|
authNativeBaseSrc,
|
||||||
|
nativeAppDir,
|
||||||
|
context,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let nativeFrameworkAuthPath = "";
|
||||||
|
if (hasNativeWind) {
|
||||||
|
nativeFrameworkAuthPath = "nativewind";
|
||||||
|
} else if (hasUnistyles) {
|
||||||
|
nativeFrameworkAuthPath = "unistyles";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nativeFrameworkAuthPath) {
|
||||||
|
const authNativeFrameworkSrc = path.join(
|
||||||
|
PKG_ROOT,
|
||||||
|
`templates/auth/native/${nativeFrameworkAuthPath}`,
|
||||||
|
);
|
||||||
|
if (await fs.pathExists(authNativeFrameworkSrc)) {
|
||||||
|
await processAndCopyFiles(
|
||||||
|
"**/*",
|
||||||
|
authNativeFrameworkSrc,
|
||||||
|
nativeAppDir,
|
||||||
|
context,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -695,6 +756,9 @@ export async function handleExtras(
|
|||||||
context: ProjectConfig,
|
context: ProjectConfig,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const extrasDir = path.join(PKG_ROOT, "templates/extras");
|
const extrasDir = path.join(PKG_ROOT, "templates/extras");
|
||||||
|
const hasNativeWind = context.frontend.includes("native-nativewind");
|
||||||
|
const hasUnistyles = context.frontend.includes("native-unistyles");
|
||||||
|
const hasNative = hasNativeWind || hasUnistyles;
|
||||||
|
|
||||||
if (context.packageManager === "pnpm") {
|
if (context.packageManager === "pnpm") {
|
||||||
const pnpmWorkspaceSrc = path.join(extrasDir, "pnpm-workspace.yaml");
|
const pnpmWorkspaceSrc = path.join(extrasDir, "pnpm-workspace.yaml");
|
||||||
@@ -706,7 +770,7 @@ export async function handleExtras(
|
|||||||
|
|
||||||
if (
|
if (
|
||||||
context.packageManager === "pnpm" &&
|
context.packageManager === "pnpm" &&
|
||||||
(context.frontend.includes("native") || context.frontend.includes("nuxt"))
|
(hasNative || context.frontend.includes("nuxt"))
|
||||||
) {
|
) {
|
||||||
const npmrcTemplateSrc = path.join(extrasDir, "_npmrc.hbs");
|
const npmrcTemplateSrc = path.join(extrasDir, "_npmrc.hbs");
|
||||||
const npmrcDest = path.join(projectDir, ".npmrc");
|
const npmrcDest = path.join(projectDir, ".npmrc");
|
||||||
|
|||||||
@@ -84,7 +84,8 @@ async function main() {
|
|||||||
"tanstack-start",
|
"tanstack-start",
|
||||||
"next",
|
"next",
|
||||||
"nuxt",
|
"nuxt",
|
||||||
"native",
|
"native-nativewind",
|
||||||
|
"native-unistyles",
|
||||||
"svelte",
|
"svelte",
|
||||||
"solid",
|
"solid",
|
||||||
"none",
|
"none",
|
||||||
@@ -303,6 +304,9 @@ async function main() {
|
|||||||
config.runtime = "none";
|
config.runtime = "none";
|
||||||
config.dbSetup = "none";
|
config.dbSetup = "none";
|
||||||
config.examples = ["todo"];
|
config.examples = ["todo"];
|
||||||
|
log.info(
|
||||||
|
"Due to '--backend convex' flag, the following options have been automatically set: auth=false, database=none, orm=none, api=none, runtime=none, dbSetup=none, examples=todo",
|
||||||
|
);
|
||||||
} else if (config.backend === "none") {
|
} else if (config.backend === "none") {
|
||||||
config.auth = false;
|
config.auth = false;
|
||||||
config.database = "none";
|
config.database = "none";
|
||||||
@@ -311,10 +315,24 @@ async function main() {
|
|||||||
config.runtime = "none";
|
config.runtime = "none";
|
||||||
config.dbSetup = "none";
|
config.dbSetup = "none";
|
||||||
config.examples = [];
|
config.examples = [];
|
||||||
|
log.info(
|
||||||
|
"Due to '--backend none', the following options have been automatically set: --auth=false, --database=none, --orm=none, --api=none, --runtime=none, --db-setup=none, --examples=none",
|
||||||
|
);
|
||||||
} else if (config.database === "none") {
|
} else if (config.database === "none") {
|
||||||
config.orm = "none";
|
config.orm = "none";
|
||||||
|
log.info(
|
||||||
|
"Due to '--database none', '--orm' has been automatically set to 'none'.",
|
||||||
|
);
|
||||||
|
|
||||||
config.auth = false;
|
config.auth = false;
|
||||||
|
log.info(
|
||||||
|
"Due to '--database none', '--auth' has been automatically set to 'false'.",
|
||||||
|
);
|
||||||
|
|
||||||
config.dbSetup = "none";
|
config.dbSetup = "none";
|
||||||
|
log.info(
|
||||||
|
"Due to '--database none', '--db-setup' has been automatically set to 'none'.",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
log.info(
|
log.info(
|
||||||
@@ -380,13 +398,16 @@ function processAndValidateFlags(
|
|||||||
if (options.api) {
|
if (options.api) {
|
||||||
config.api = options.api as ProjectApi;
|
config.api = options.api as ProjectApi;
|
||||||
if (options.api === "none") {
|
if (options.api === "none") {
|
||||||
if (options.backend && options.backend !== "convex") {
|
if (
|
||||||
|
options.backend &&
|
||||||
|
options.backend !== "convex" &&
|
||||||
|
options.backend !== "none"
|
||||||
|
) {
|
||||||
consola.fatal(
|
consola.fatal(
|
||||||
`'--api none' is only supported with '--backend convex'. Please choose a different API setting or use '--backend convex'.`,
|
`'--api none' is only supported with '--backend convex' or '--backend none'. Please choose a different API setting or use '--backend convex' or '--backend none'.`,
|
||||||
);
|
);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
config.backend = "convex";
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -468,12 +489,22 @@ function processAndValidateFlags(
|
|||||||
f === "svelte" ||
|
f === "svelte" ||
|
||||||
f === "solid",
|
f === "solid",
|
||||||
);
|
);
|
||||||
|
const nativeFrontends = validOptions.filter(
|
||||||
|
(f) => f === "native-nativewind" || f === "native-unistyles",
|
||||||
|
);
|
||||||
|
|
||||||
if (webFrontends.length > 1) {
|
if (webFrontends.length > 1) {
|
||||||
consola.fatal(
|
consola.fatal(
|
||||||
"Cannot select multiple web frameworks. Choose only one of: tanstack-router, tanstack-start, react-router, next, nuxt, svelte, solid",
|
"Cannot select multiple web frameworks. Choose only one of: tanstack-router, tanstack-start, react-router, next, nuxt, svelte, solid",
|
||||||
);
|
);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
if (nativeFrontends.length > 1) {
|
||||||
|
consola.fatal(
|
||||||
|
"Cannot select multiple native frameworks. Choose only one of: native-nativewind, native-unistyles",
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
config.frontend = validOptions;
|
config.frontend = validOptions;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -595,6 +626,9 @@ function processAndValidateFlags(
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
config.examples = [];
|
config.examples = [];
|
||||||
|
log.info(
|
||||||
|
"Due to '--backend none', the following options have been automatically set: --auth=false, --database=none, --orm=none, --api=none, --runtime=none, --db-setup=none, --examples=none",
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
const effectiveDatabase =
|
const effectiveDatabase =
|
||||||
config.database ?? (options.yes ? DEFAULT_CONFIG.database : undefined);
|
config.database ?? (options.yes ? DEFAULT_CONFIG.database : undefined);
|
||||||
@@ -621,6 +655,9 @@ function processAndValidateFlags(
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
config.orm = "none";
|
config.orm = "none";
|
||||||
|
log.info(
|
||||||
|
"Due to '--database none', '--orm' has been automatically set to 'none'.",
|
||||||
|
);
|
||||||
|
|
||||||
if (providedFlags.has("auth") && options.auth === true) {
|
if (providedFlags.has("auth") && options.auth === true) {
|
||||||
consola.fatal(
|
consola.fatal(
|
||||||
@@ -629,6 +666,9 @@ function processAndValidateFlags(
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
config.auth = false;
|
config.auth = false;
|
||||||
|
log.info(
|
||||||
|
"Due to '--database none', '--auth' has been automatically set to 'false'.",
|
||||||
|
);
|
||||||
|
|
||||||
if (providedFlags.has("dbSetup") && options.dbSetup !== "none") {
|
if (providedFlags.has("dbSetup") && options.dbSetup !== "none") {
|
||||||
consola.fatal(
|
consola.fatal(
|
||||||
@@ -637,6 +677,9 @@ function processAndValidateFlags(
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
config.dbSetup = "none";
|
config.dbSetup = "none";
|
||||||
|
log.info(
|
||||||
|
"Due to '--database none', '--db-setup' has been automatically set to 'none'.",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (config.orm === "mongoose" && !providedFlags.has("database")) {
|
if (config.orm === "mongoose" && !providedFlags.has("database")) {
|
||||||
@@ -753,6 +796,9 @@ function processAndValidateFlags(
|
|||||||
) {
|
) {
|
||||||
if (config.api !== "none") {
|
if (config.api !== "none") {
|
||||||
config.api = "orpc";
|
config.api = "orpc";
|
||||||
|
log.info(
|
||||||
|
`Due to frontend selection, API has been set to 'orpc'. tRPC is not compatible with Nuxt, Svelte, or Solid Framework`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -813,7 +859,8 @@ function processAndValidateFlags(
|
|||||||
const onlyNativeFrontend =
|
const onlyNativeFrontend =
|
||||||
effectiveFrontend &&
|
effectiveFrontend &&
|
||||||
effectiveFrontend.length === 1 &&
|
effectiveFrontend.length === 1 &&
|
||||||
effectiveFrontend[0] === "native";
|
(effectiveFrontend[0] === "native-nativewind" ||
|
||||||
|
effectiveFrontend[0] === "native-unistyles");
|
||||||
|
|
||||||
if (
|
if (
|
||||||
onlyNativeFrontend &&
|
onlyNativeFrontend &&
|
||||||
@@ -822,7 +869,7 @@ function processAndValidateFlags(
|
|||||||
!config.examples.includes("none")
|
!config.examples.includes("none")
|
||||||
) {
|
) {
|
||||||
consola.fatal(
|
consola.fatal(
|
||||||
"Examples are not supported when only the 'native' frontend is selected.",
|
"Examples are not supported when only a native frontend (NativeWind or Unistyles) is selected.",
|
||||||
);
|
);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,7 +48,6 @@ export async function getBackendFrameworkChoice(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add "None" option
|
|
||||||
backendOptions.push({
|
backendOptions.push({
|
||||||
value: "none" as const,
|
value: "none" as const,
|
||||||
label: "None",
|
label: "None",
|
||||||
|
|||||||
@@ -27,7 +27,10 @@ export async function getExamplesChoice(
|
|||||||
if (database === "none") return [];
|
if (database === "none") return [];
|
||||||
|
|
||||||
const onlyNative =
|
const onlyNative =
|
||||||
frontends && frontends.length === 1 && frontends[0] === "native";
|
frontends &&
|
||||||
|
frontends.length === 1 &&
|
||||||
|
(frontends[0] === "native-nativewind" ||
|
||||||
|
frontends[0] === "native-unistyles");
|
||||||
if (onlyNative) {
|
if (onlyNative) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -95,7 +95,28 @@ export async function getFrontendChoice(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (frontendTypes.includes("native")) {
|
if (frontendTypes.includes("native")) {
|
||||||
result.push("native");
|
const nativeFramework = await select<ProjectFrontend>({
|
||||||
|
message: "Choose native framework",
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
value: "native-nativewind" as const,
|
||||||
|
label: "NativeWind",
|
||||||
|
hint: "Use Tailwind CSS for React Native",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "native-unistyles" as const,
|
||||||
|
label: "Unistyles",
|
||||||
|
hint: "Consistent styling for React Native",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
initialValue: "native-nativewind",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isCancel(nativeFramework)) {
|
||||||
|
cancel(pc.red("Operation cancelled"));
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
result.push(nativeFramework);
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
|
|||||||
@@ -29,7 +29,8 @@ export type ProjectFrontend =
|
|||||||
| "tanstack-start"
|
| "tanstack-start"
|
||||||
| "next"
|
| "next"
|
||||||
| "nuxt"
|
| "nuxt"
|
||||||
| "native"
|
| "native-nativewind"
|
||||||
|
| "native-unistyles"
|
||||||
| "svelte"
|
| "svelte"
|
||||||
| "solid"
|
| "solid"
|
||||||
| "none";
|
| "none";
|
||||||
|
|||||||
@@ -0,0 +1,179 @@
|
|||||||
|
import { authClient } from "@/lib/auth-client";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { ScrollView, Text, TouchableOpacity, View } from "react-native";
|
||||||
|
import { StyleSheet } from "react-native-unistyles";
|
||||||
|
|
||||||
|
import { Container } from "@/components/container";
|
||||||
|
import { SignIn } from "@/components/sign-in";
|
||||||
|
import { SignUp } from "@/components/sign-up";
|
||||||
|
{{#if (eq api "orpc")}}
|
||||||
|
import { queryClient, orpc } from "@/utils/orpc";
|
||||||
|
{{/if}}
|
||||||
|
{{#if (eq api "trpc")}}
|
||||||
|
import { queryClient, trpc } from "@/utils/trpc";
|
||||||
|
{{/if}}
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
{{#if (eq api "orpc")}}
|
||||||
|
const healthCheck = useQuery(orpc.healthCheck.queryOptions());
|
||||||
|
const privateData = useQuery(orpc.privateData.queryOptions());
|
||||||
|
{{/if}}
|
||||||
|
{{#if (eq api "trpc")}}
|
||||||
|
const healthCheck = useQuery(trpc.healthCheck.queryOptions());
|
||||||
|
const privateData = useQuery(trpc.privateData.queryOptions());
|
||||||
|
{{/if}}
|
||||||
|
const { data: session } = authClient.useSession();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container>
|
||||||
|
<ScrollView>
|
||||||
|
<View style={styles.pageContainer}>
|
||||||
|
<Text style={styles.headerTitle}>BETTER T STACK</Text>
|
||||||
|
{session?.user ? (
|
||||||
|
<View style={styles.sessionInfoCard}>
|
||||||
|
<View style={styles.sessionUserRow}>
|
||||||
|
<Text style={styles.welcomeText}>
|
||||||
|
Welcome,{" "}
|
||||||
|
<Text style={styles.userNameText}>{session.user.name}</Text>
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={styles.emailText}>{session.user.email}</Text>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.signOutButton}
|
||||||
|
onPress={() => {
|
||||||
|
authClient.signOut();
|
||||||
|
queryClient.invalidateQueries();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={styles.signOutButtonText}>Sign Out</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
<View style={styles.apiStatusCard}>
|
||||||
|
<Text style={styles.cardTitle}>API Status</Text>
|
||||||
|
<View style={styles.apiStatusRow}>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.statusIndicatorDot,
|
||||||
|
healthCheck.data
|
||||||
|
? styles.statusIndicatorGreen
|
||||||
|
: styles.statusIndicatorRed,
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<Text style={styles.mutedText}>
|
||||||
|
{healthCheck.isLoading
|
||||||
|
? "Checking..."
|
||||||
|
: healthCheck.data
|
||||||
|
? "Connected to API"
|
||||||
|
: "API Disconnected"}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<View style={styles.privateDataCard}>
|
||||||
|
<Text style={styles.cardTitle}>Private Data</Text>
|
||||||
|
{privateData && (
|
||||||
|
<View>
|
||||||
|
<Text style={styles.mutedText}>
|
||||||
|
{privateData.data?.message}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
{!session?.user && (
|
||||||
|
<>
|
||||||
|
<SignIn />
|
||||||
|
<SignUp />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create((theme) => ({
|
||||||
|
pageContainer: {
|
||||||
|
paddingHorizontal: 8,
|
||||||
|
},
|
||||||
|
headerTitle: {
|
||||||
|
color: theme?.colors?.typography,
|
||||||
|
fontSize: 30,
|
||||||
|
fontWeight: "bold",
|
||||||
|
marginBottom: 16,
|
||||||
|
},
|
||||||
|
sessionInfoCard: {
|
||||||
|
marginBottom: 24,
|
||||||
|
padding: 16,
|
||||||
|
borderRadius: 8,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: theme?.colors?.border,
|
||||||
|
},
|
||||||
|
sessionUserRow: {
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
welcomeText: {
|
||||||
|
color: theme?.colors?.typography,
|
||||||
|
fontSize: 16,
|
||||||
|
},
|
||||||
|
userNameText: {
|
||||||
|
fontWeight: "500",
|
||||||
|
color: theme?.colors?.typography,
|
||||||
|
},
|
||||||
|
emailText: {
|
||||||
|
color: theme?.colors?.typography,
|
||||||
|
fontSize: 14,
|
||||||
|
marginBottom: 16,
|
||||||
|
},
|
||||||
|
signOutButton: {
|
||||||
|
backgroundColor: theme?.colors?.destructive,
|
||||||
|
paddingVertical: 8,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
borderRadius: 6,
|
||||||
|
alignSelf: "flex-start",
|
||||||
|
},
|
||||||
|
signOutButtonText: {
|
||||||
|
fontWeight: "500",
|
||||||
|
},
|
||||||
|
apiStatusCard: {
|
||||||
|
marginBottom: 24,
|
||||||
|
borderRadius: 8,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: theme?.colors?.border,
|
||||||
|
padding: 16,
|
||||||
|
},
|
||||||
|
cardTitle: {
|
||||||
|
marginBottom: 12,
|
||||||
|
fontWeight: "500",
|
||||||
|
color: theme?.colors?.typography,
|
||||||
|
},
|
||||||
|
apiStatusRow: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 8,
|
||||||
|
},
|
||||||
|
statusIndicatorDot: {
|
||||||
|
height: 12,
|
||||||
|
width: 12,
|
||||||
|
borderRadius: 9999,
|
||||||
|
},
|
||||||
|
statusIndicatorGreen: {
|
||||||
|
backgroundColor: theme.colors.success,
|
||||||
|
},
|
||||||
|
statusIndicatorRed: {
|
||||||
|
backgroundColor: theme.colors.destructive,
|
||||||
|
},
|
||||||
|
mutedText: {
|
||||||
|
color: theme?.colors?.typography,
|
||||||
|
},
|
||||||
|
privateDataCard: {
|
||||||
|
marginBottom: 24,
|
||||||
|
borderRadius: 8,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: theme?.colors?.border,
|
||||||
|
padding: 16,
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
import { authClient } from "@/lib/auth-client";
|
||||||
|
{{#if (eq api "trpc")}}
|
||||||
|
import { queryClient } from "@/utils/trpc";
|
||||||
|
{{/if}}
|
||||||
|
{{#if (eq api "orpc")}}
|
||||||
|
import { queryClient } from "@/utils/orpc";
|
||||||
|
{{/if}}
|
||||||
|
import { useState } from "react";
|
||||||
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
TouchableOpacity,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
import { StyleSheet } from "react-native-unistyles";
|
||||||
|
|
||||||
|
export function SignIn() {
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleLogin = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
await authClient.signIn.email(
|
||||||
|
{
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onError: (error) => {
|
||||||
|
setError(error.error?.message || "Failed to sign in");
|
||||||
|
setIsLoading(false);
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
setEmail("");
|
||||||
|
setPassword("");
|
||||||
|
queryClient.refetchQueries();
|
||||||
|
},
|
||||||
|
onFinished: () => {
|
||||||
|
setIsLoading(false);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<Text style={styles.title}>Sign In</Text>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<View style={styles.errorContainer}>
|
||||||
|
<Text style={styles.errorText}>{error}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
style={styles.input}
|
||||||
|
placeholder="Email"
|
||||||
|
value={email}
|
||||||
|
onChangeText={setEmail}
|
||||||
|
keyboardType="email-address"
|
||||||
|
autoCapitalize="none"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
style={styles.input}
|
||||||
|
placeholder="Password"
|
||||||
|
value={password}
|
||||||
|
onChangeText={setPassword}
|
||||||
|
secureTextEntry
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handleLogin}
|
||||||
|
disabled={isLoading}
|
||||||
|
style={styles.button}
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<ActivityIndicator size="small" color="#fff" />
|
||||||
|
) : (
|
||||||
|
<Text style={styles.buttonText}>Sign In</Text>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create((theme) => ({
|
||||||
|
container: {
|
||||||
|
marginTop: 24,
|
||||||
|
padding: 16,
|
||||||
|
borderRadius: 8,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: theme.colors.border,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: "600",
|
||||||
|
color: theme.colors.typography,
|
||||||
|
marginBottom: 16,
|
||||||
|
},
|
||||||
|
errorContainer: {
|
||||||
|
marginBottom: 16,
|
||||||
|
padding: 12,
|
||||||
|
borderRadius: 6,
|
||||||
|
},
|
||||||
|
errorText: {
|
||||||
|
color: theme.colors.destructive,
|
||||||
|
fontSize: 14,
|
||||||
|
},
|
||||||
|
input: {
|
||||||
|
marginBottom: 12,
|
||||||
|
padding: 16,
|
||||||
|
borderRadius: 6,
|
||||||
|
color: theme.colors.typography,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: theme.colors.border,
|
||||||
|
},
|
||||||
|
button: {
|
||||||
|
backgroundColor: theme.colors.primary,
|
||||||
|
padding: 16,
|
||||||
|
borderRadius: 6,
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
buttonText: {
|
||||||
|
fontWeight: "500",
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
import { authClient } from "@/lib/auth-client";
|
||||||
|
{{#if (eq api "trpc")}}
|
||||||
|
import { queryClient } from "@/utils/trpc";
|
||||||
|
{{/if}}
|
||||||
|
{{#if (eq api "orpc")}}
|
||||||
|
import { queryClient } from "@/utils/orpc";
|
||||||
|
{{/if}}
|
||||||
|
import { useState } from "react";
|
||||||
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
TouchableOpacity,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
import { StyleSheet } from "react-native-unistyles";
|
||||||
|
|
||||||
|
export function SignUp() {
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleSignUp = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
await authClient.signUp.email(
|
||||||
|
{
|
||||||
|
name,
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onError: (error) => {
|
||||||
|
setError(error.error?.message || "Failed to sign up");
|
||||||
|
setIsLoading(false);
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
setName("");
|
||||||
|
setEmail("");
|
||||||
|
setPassword("");
|
||||||
|
queryClient.refetchQueries();
|
||||||
|
},
|
||||||
|
onFinished: () => {
|
||||||
|
setIsLoading(false);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<Text style={styles.title}>Create Account</Text>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<View style={styles.errorContainer}>
|
||||||
|
<Text style={styles.errorText}>{error}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
style={styles.input}
|
||||||
|
placeholder="Name"
|
||||||
|
value={name}
|
||||||
|
onChangeText={setName}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
style={styles.input}
|
||||||
|
placeholder="Email"
|
||||||
|
value={email}
|
||||||
|
onChangeText={setEmail}
|
||||||
|
keyboardType="email-address"
|
||||||
|
autoCapitalize="none"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
style={styles.inputLast}
|
||||||
|
placeholder="Password"
|
||||||
|
value={password}
|
||||||
|
onChangeText={setPassword}
|
||||||
|
secureTextEntry
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handleSignUp}
|
||||||
|
disabled={isLoading}
|
||||||
|
style={styles.button}
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<ActivityIndicator size="small" color="#fff" />
|
||||||
|
) : (
|
||||||
|
<Text style={styles.buttonText}>Sign Up</Text>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create((theme) => ({
|
||||||
|
container: {
|
||||||
|
marginTop: 24,
|
||||||
|
padding: 16,
|
||||||
|
borderRadius: 8,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: theme.colors.border,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: "600",
|
||||||
|
color: theme.colors.typography,
|
||||||
|
marginBottom: 16,
|
||||||
|
},
|
||||||
|
errorContainer: {
|
||||||
|
marginBottom: 16,
|
||||||
|
padding: 12,
|
||||||
|
borderRadius: 6,
|
||||||
|
},
|
||||||
|
errorText: {
|
||||||
|
color: theme.colors.destructive,
|
||||||
|
fontSize: 14,
|
||||||
|
},
|
||||||
|
input: {
|
||||||
|
marginBottom: 12,
|
||||||
|
padding: 16,
|
||||||
|
borderRadius: 6,
|
||||||
|
color: theme.colors.typography,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: theme.colors.border,
|
||||||
|
},
|
||||||
|
inputLast: {
|
||||||
|
marginBottom: 16,
|
||||||
|
padding: 16,
|
||||||
|
borderRadius: 6,
|
||||||
|
color: theme.colors.typography,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: theme.colors.border,
|
||||||
|
},
|
||||||
|
button: {
|
||||||
|
backgroundColor: theme.colors.primary,
|
||||||
|
padding: 16,
|
||||||
|
borderRadius: 6,
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
buttonText: {
|
||||||
|
fontWeight: "500",
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 46 KiB |
24
apps/cli/templates/frontend/native/unistyles/_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*
|
||||||
44
apps/cli/templates/frontend/native/unistyles/app.json
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
{
|
||||||
|
"expo": {
|
||||||
|
"name": "my-better-t-app",
|
||||||
|
"slug": "my-better-t-app",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"newArchEnabled": true,
|
||||||
|
"scheme": "my-better-t-app",
|
||||||
|
"web": {
|
||||||
|
"bundler": "metro",
|
||||||
|
"output": "static",
|
||||||
|
"favicon": "./assets/favicon.png"
|
||||||
|
},
|
||||||
|
"plugins": [
|
||||||
|
"expo-router",
|
||||||
|
"react-native-edge-to-edge",
|
||||||
|
"expo-secure-store"
|
||||||
|
],
|
||||||
|
"experiments": {
|
||||||
|
"typedRoutes": true,
|
||||||
|
"tsconfigPaths": true,
|
||||||
|
"reactCompiler": true
|
||||||
|
},
|
||||||
|
"orientation": "portrait",
|
||||||
|
"icon": "./assets/icon.png",
|
||||||
|
"userInterfaceStyle": "automatic",
|
||||||
|
"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,34 @@
|
|||||||
|
import { Tabs } from "expo-router";
|
||||||
|
import { useUnistyles } from "react-native-unistyles";
|
||||||
|
|
||||||
|
import { TabBarIcon } from "@/components/tabbar-icon";
|
||||||
|
|
||||||
|
export default function TabLayout() {
|
||||||
|
const { theme } = useUnistyles();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tabs
|
||||||
|
screenOptions={{
|
||||||
|
headerShown: false,
|
||||||
|
tabBarStyle: {
|
||||||
|
backgroundColor: theme.colors.background,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<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,29 @@
|
|||||||
|
import { Stack } from "expo-router";
|
||||||
|
import { StyleSheet } from "react-native-unistyles";
|
||||||
|
import { Container } from "@/components/container";
|
||||||
|
import { Text, View } from "react-native";
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Stack.Screen options={{ title: "Tab One" }} />
|
||||||
|
<Container>
|
||||||
|
<View style={styles.container}>
|
||||||
|
<Text style={styles.text}>Tab One</Text>
|
||||||
|
</View>
|
||||||
|
</Container>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create((theme) => ({
|
||||||
|
text: {
|
||||||
|
color: theme.colors.typography,
|
||||||
|
},
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
paddingBottom: 100,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { Stack } from "expo-router";
|
||||||
|
import { StyleSheet } from "react-native-unistyles";
|
||||||
|
import { Container } from "@/components/container";
|
||||||
|
import { Text, View } from "react-native";
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Stack.Screen options={{ title: "Tab Two" }} />
|
||||||
|
<Container>
|
||||||
|
<View style={styles.container}>
|
||||||
|
<Text style={styles.text}>Tab Two</Text>
|
||||||
|
</View>
|
||||||
|
</Container>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create((theme) => ({
|
||||||
|
text: {
|
||||||
|
color: theme.colors.typography,
|
||||||
|
},
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
paddingBottom: 100,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { Ionicons, MaterialIcons } from "@expo/vector-icons";
|
||||||
|
import { Link } from "expo-router";
|
||||||
|
import { Drawer } from "expo-router/drawer";
|
||||||
|
import { useUnistyles } from "react-native-unistyles";
|
||||||
|
|
||||||
|
import { HeaderButton } from "../../components/header-button";
|
||||||
|
|
||||||
|
const DrawerLayout = () => {
|
||||||
|
const { theme } = useUnistyles();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Drawer
|
||||||
|
screenOptions={{
|
||||||
|
headerStyle: {
|
||||||
|
backgroundColor: theme.colors.background,
|
||||||
|
},
|
||||||
|
headerTitleStyle: {
|
||||||
|
color: theme.colors.typography,
|
||||||
|
},
|
||||||
|
headerTintColor: theme.colors.typography,
|
||||||
|
drawerStyle: {
|
||||||
|
backgroundColor: theme.colors.background,
|
||||||
|
},
|
||||||
|
drawerLabelStyle: {
|
||||||
|
color: theme.colors.typography,
|
||||||
|
},
|
||||||
|
drawerInactiveTintColor: theme.colors.typography,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<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;
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import { ScrollView, Text, View } from "react-native";
|
||||||
|
import { StyleSheet } from "react-native-unistyles";
|
||||||
|
import { Container } from "@/components/container";
|
||||||
|
|
||||||
|
{{#if (eq api "orpc")}}
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { orpc } from "@/utils/orpc";
|
||||||
|
{{/if}}
|
||||||
|
{{#if (eq api "trpc")}}
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { trpc } from "@/utils/trpc";
|
||||||
|
{{/if}}
|
||||||
|
{{#if (eq backend "convex")}}
|
||||||
|
import { useQuery } from "convex/react";
|
||||||
|
import { api } from "@{{ projectName }}/backend/convex/_generated/api.js";
|
||||||
|
{{/if}}
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
{{#if (eq api "orpc")}}
|
||||||
|
const healthCheck = useQuery(orpc.healthCheck.queryOptions());
|
||||||
|
{{/if}}
|
||||||
|
{{#if (eq api "trpc")}}
|
||||||
|
const healthCheck = useQuery(trpc.healthCheck.queryOptions());
|
||||||
|
{{/if}}
|
||||||
|
{{#if (eq backend "convex")}}
|
||||||
|
const healthCheck = useQuery(api.healthCheck.get);
|
||||||
|
{{/if}}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container>
|
||||||
|
<ScrollView contentContainerStyle={styles.pageContainer}>
|
||||||
|
<Text style={styles.headerTitle}>BETTER T STACK</Text>
|
||||||
|
|
||||||
|
<View style={styles.apiStatusCard}>
|
||||||
|
<Text style={styles.cardTitle}>API Status</Text>
|
||||||
|
<View style={styles.apiStatusRow}>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.statusIndicatorDot,
|
||||||
|
{{#if (or (eq api "orpc") (eq api "trpc"))}}
|
||||||
|
healthCheck.data
|
||||||
|
? styles.statusIndicatorGreen
|
||||||
|
: styles.statusIndicatorRed,
|
||||||
|
{{else}}
|
||||||
|
healthCheck === "OK"
|
||||||
|
? styles.statusIndicatorGreen
|
||||||
|
: styles.statusIndicatorRed,
|
||||||
|
{{/if}}
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<Text style={styles.statusText}>
|
||||||
|
{{#if (or (eq api "orpc") (eq api "trpc"))}}
|
||||||
|
{healthCheck.isLoading
|
||||||
|
? "Checking..."
|
||||||
|
: healthCheck.data
|
||||||
|
? "Connected"
|
||||||
|
: "Disconnected"}
|
||||||
|
{{/if}}
|
||||||
|
{{#if (eq backend "convex")}}
|
||||||
|
{healthCheck === undefined
|
||||||
|
? "Checking..."
|
||||||
|
: healthCheck === "OK"
|
||||||
|
? "Connected"
|
||||||
|
: "Error"}
|
||||||
|
{{/if}}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create((theme) => ({
|
||||||
|
pageContainer: {
|
||||||
|
paddingHorizontal: 8,
|
||||||
|
},
|
||||||
|
headerTitle: {
|
||||||
|
color: theme?.colors?.typography,
|
||||||
|
fontSize: 30,
|
||||||
|
fontWeight: "bold",
|
||||||
|
marginBottom: 16,
|
||||||
|
},
|
||||||
|
apiStatusCard: {
|
||||||
|
marginBottom: 24,
|
||||||
|
borderRadius: 8,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: theme?.colors?.border,
|
||||||
|
padding: 16,
|
||||||
|
},
|
||||||
|
cardTitle: {
|
||||||
|
marginBottom: 12,
|
||||||
|
fontWeight: "500",
|
||||||
|
color: theme?.colors?.typography,
|
||||||
|
},
|
||||||
|
apiStatusRow: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 8,
|
||||||
|
},
|
||||||
|
statusIndicatorDot: {
|
||||||
|
height: 12,
|
||||||
|
width: 12,
|
||||||
|
borderRadius: 9999,
|
||||||
|
},
|
||||||
|
statusIndicatorGreen: {
|
||||||
|
backgroundColor: theme.colors.success,
|
||||||
|
},
|
||||||
|
statusIndicatorRed: {
|
||||||
|
backgroundColor: theme.colors.destructive,
|
||||||
|
},
|
||||||
|
statusText: {
|
||||||
|
color: theme?.colors?.typography,
|
||||||
|
},
|
||||||
|
}));
|
||||||
48
apps/cli/templates/frontend/native/unistyles/app/+html.tsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { ScrollViewStyleReset } from 'expo-router/html';
|
||||||
|
|
||||||
|
import '../unistyles';
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
}`;
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { Link, Stack } from "expo-router";
|
||||||
|
import { Text } from "react-native";
|
||||||
|
import { StyleSheet } from "react-native-unistyles";
|
||||||
|
|
||||||
|
import { Container } from "@/components/container";
|
||||||
|
|
||||||
|
export default function NotFoundScreen() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Stack.Screen options={{ title: "Oops!" }} />
|
||||||
|
<Container>
|
||||||
|
<Text style={styles.title}>This screen doesn't exist.</Text>
|
||||||
|
<Link href="/" style={styles.link}>
|
||||||
|
<Text style={styles.linkText}>Go to home screen!</Text>
|
||||||
|
</Link>
|
||||||
|
</Container>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create((theme) => ({
|
||||||
|
title: {
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: "bold",
|
||||||
|
color: theme.colors.typography,
|
||||||
|
},
|
||||||
|
link: {
|
||||||
|
marginTop: 16,
|
||||||
|
paddingVertical: 16,
|
||||||
|
},
|
||||||
|
linkText: {
|
||||||
|
fontSize: 14,
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
{{#if (eq api "trpc")}}
|
||||||
|
import { queryClient } from "@/utils/trpc";
|
||||||
|
{{/if}}
|
||||||
|
{{#if (eq api "orpc")}}
|
||||||
|
import { queryClient } from "@/utils/orpc";
|
||||||
|
{{/if}}
|
||||||
|
{{#if (eq backend "convex")}}
|
||||||
|
import { ConvexProvider, ConvexReactClient } from "convex/react";
|
||||||
|
{{else}}
|
||||||
|
import { QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
{{/if}}
|
||||||
|
import { Stack } from "expo-router";
|
||||||
|
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
||||||
|
import { useUnistyles } from "react-native-unistyles";
|
||||||
|
|
||||||
|
export const unstable_settings = {
|
||||||
|
// Ensure that reloading on `/modal` keeps a back button present.
|
||||||
|
initialRouteName: "(drawer)",
|
||||||
|
};
|
||||||
|
|
||||||
|
{{#if (eq backend "convex")}}
|
||||||
|
const convex = new ConvexReactClient(process.env.EXPO_PUBLIC_CONVEX_URL!, {
|
||||||
|
unsavedChangesWarning: false,
|
||||||
|
});
|
||||||
|
{{/if}}
|
||||||
|
|
||||||
|
export default function RootLayout() {
|
||||||
|
const { theme } = useUnistyles();
|
||||||
|
|
||||||
|
return (
|
||||||
|
{{#if (eq backend "convex")}}
|
||||||
|
<ConvexProvider client={convex}>
|
||||||
|
<GestureHandlerRootView style=\{{ flex: 1 }}>
|
||||||
|
<Stack
|
||||||
|
screenOptions=\{{
|
||||||
|
headerStyle: {
|
||||||
|
backgroundColor: theme.colors.background,
|
||||||
|
},
|
||||||
|
headerTitleStyle: {
|
||||||
|
color: theme.colors.typography,
|
||||||
|
},
|
||||||
|
headerTintColor: theme.colors.typography,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack.Screen name="(drawer)" options=\{{ headerShown: false }} />
|
||||||
|
<Stack.Screen
|
||||||
|
name="modal"
|
||||||
|
options=\{{ title: "Modal", presentation: "modal" }}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
</GestureHandlerRootView>
|
||||||
|
</ConvexProvider>
|
||||||
|
{{else}}
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<GestureHandlerRootView style=\{{ flex: 1 }}>
|
||||||
|
<Stack
|
||||||
|
screenOptions=\{{
|
||||||
|
headerStyle: {
|
||||||
|
backgroundColor: theme.colors.background,
|
||||||
|
},
|
||||||
|
headerTitleStyle: {
|
||||||
|
color: theme.colors.typography,
|
||||||
|
},
|
||||||
|
headerTintColor: theme.colors.typography,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack.Screen name="(drawer)" options=\{{ headerShown: false }} />
|
||||||
|
<Stack.Screen
|
||||||
|
name="modal"
|
||||||
|
options=\{{ title: "Modal", presentation: "modal" }}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
</GestureHandlerRootView>
|
||||||
|
</QueryClientProvider>
|
||||||
|
{{/if}}
|
||||||
|
);
|
||||||
|
}
|
||||||
29
apps/cli/templates/frontend/native/unistyles/app/modal.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { Container } from "@/components/container";
|
||||||
|
import { StatusBar } from "expo-status-bar";
|
||||||
|
import { Platform, Text, View } from "react-native";
|
||||||
|
import { StyleSheet } from "react-native-unistyles";
|
||||||
|
|
||||||
|
export default function Modal() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<StatusBar style={Platform.OS === "ios" ? "light" : "auto"} />
|
||||||
|
<Container>
|
||||||
|
<View style={styles.container}>
|
||||||
|
<Text style={styles.text}>Model</Text>
|
||||||
|
</View>
|
||||||
|
</Container>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create((theme) => ({
|
||||||
|
text: {
|
||||||
|
color: theme.colors.typography,
|
||||||
|
},
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
paddingBottom: 100,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
}));
|
||||||
20
apps/cli/templates/frontend/native/unistyles/babel.config.js
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
module.exports = function (api) {
|
||||||
|
api.cache(true);
|
||||||
|
const plugins = [];
|
||||||
|
|
||||||
|
plugins.push([
|
||||||
|
'react-native-unistyles/plugin',
|
||||||
|
{
|
||||||
|
autoProcessRoot: 'app',
|
||||||
|
autoProcessImports: ['@/components'],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
plugins.push('react-native-reanimated/plugin');
|
||||||
|
|
||||||
|
return {
|
||||||
|
presets: ['babel-preset-expo'],
|
||||||
|
|
||||||
|
plugins,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
export const breakpoints = {
|
||||||
|
xs: 0,
|
||||||
|
sm: 576,
|
||||||
|
md: 768,
|
||||||
|
lg: 992,
|
||||||
|
xl: 1200,
|
||||||
|
superLarge: 2000,
|
||||||
|
tvLike: 4000,
|
||||||
|
} as const;
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { View } from "react-native";
|
||||||
|
import { StyleSheet } from "react-native-unistyles";
|
||||||
|
|
||||||
|
export const Container = ({ children }: { children: React.ReactNode }) => {
|
||||||
|
return <View style={styles.container}>{children}</View>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create((theme, rt) => ({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
paddingBottom: rt.insets.bottom,
|
||||||
|
backgroundColor: theme.colors.background,
|
||||||
|
transform: [
|
||||||
|
{
|
||||||
|
translateY: rt.insets.ime * -1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
3
apps/cli/templates/frontend/native/unistyles/expo-env.d.ts
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
/// <reference types="expo/types" />
|
||||||
|
|
||||||
|
// NOTE: This file should not be edited and should be in your git ignore
|
||||||
2
apps/cli/templates/frontend/native/unistyles/index.js
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
import 'expo-router/entry';
|
||||||
|
import './unistyles';
|
||||||
20
apps/cli/templates/frontend/native/unistyles/metro.config.js
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
// Learn more https://docs.expo.io/guides/customizing-metro
|
||||||
|
const { getDefaultConfig } = require("expo/metro-config");
|
||||||
|
const path = require("path");
|
||||||
|
|
||||||
|
const workspaceRoot = path.resolve(__dirname, "../..");
|
||||||
|
const projectRoot = __dirname;
|
||||||
|
|
||||||
|
const config = getDefaultConfig(projectRoot);
|
||||||
|
|
||||||
|
// 1. Watch all files within the monorepo
|
||||||
|
config.watchFolders = [workspaceRoot];
|
||||||
|
// 2. Let Metro know where to resolve packages, and in what order
|
||||||
|
config.resolver.nodeModulesPaths = [
|
||||||
|
path.resolve(projectRoot, "node_modules"),
|
||||||
|
path.resolve(workspaceRoot, "node_modules"),
|
||||||
|
];
|
||||||
|
// 3. Force Metro to resolve (sub)dependencies only from the `nodeModulesPaths`
|
||||||
|
config.resolver.disableHierarchicalLookup = true;
|
||||||
|
|
||||||
|
module.exports = config;
|
||||||
47
apps/cli/templates/frontend/native/unistyles/package.json
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
{
|
||||||
|
"name": "native",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"main": "index.js",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "expo start --clear",
|
||||||
|
"android": "expo run:android",
|
||||||
|
"ios": "expo run:ios",
|
||||||
|
"web": "expo start --web"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@better-auth/expo": "^1.2.7",
|
||||||
|
"@expo/vector-icons": "^14.0.0",
|
||||||
|
"@react-navigation/bottom-tabs": "^7.0.5",
|
||||||
|
"@react-navigation/drawer": "^7.0.0",
|
||||||
|
"@react-navigation/native": "^7.0.3",
|
||||||
|
"@tanstack/react-form": "^1.0.5",
|
||||||
|
"babel-plugin-react-compiler": "^19.0.0-beta-af1b7da-20250417",
|
||||||
|
"expo": "^53.0.8",
|
||||||
|
"expo-constants": "~17.1.4",
|
||||||
|
"expo-linking": "~7.1.4",
|
||||||
|
"expo-router": "~5.0.3",
|
||||||
|
"expo-secure-store": "~14.2.3",
|
||||||
|
"expo-status-bar": "~2.2.3",
|
||||||
|
"expo-system-ui": "~5.0.6",
|
||||||
|
"expo-dev-client": "~5.1.8",
|
||||||
|
"expo-web-browser": "~14.1.6",
|
||||||
|
"react": "19.0.0",
|
||||||
|
"react-dom": "19.0.0",
|
||||||
|
"react-native": "0.79.2",
|
||||||
|
"react-native-edge-to-edge": "1.6.0",
|
||||||
|
"react-native-gesture-handler": "~2.24.0",
|
||||||
|
"react-native-nitro-modules": "0.25.2",
|
||||||
|
"react-native-reanimated": "~3.17.4",
|
||||||
|
"react-native-safe-area-context": "5.4.0",
|
||||||
|
"react-native-screens": "~4.10.0",
|
||||||
|
"react-native-unistyles": "3.0.0-rc.3",
|
||||||
|
"react-native-web": "^0.20.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"ajv": "^8.12.0",
|
||||||
|
"@babel/core": "^7.20.0",
|
||||||
|
"@types/react": "~19.0.10",
|
||||||
|
"typescript": "~5.8.3"
|
||||||
|
},
|
||||||
|
"private": true
|
||||||
|
}
|
||||||
35
apps/cli/templates/frontend/native/unistyles/theme.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
const sharedColors = {
|
||||||
|
success: "#22C55E",
|
||||||
|
destructive: "#DC2626",
|
||||||
|
border: "#D1D5DB",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const lightTheme = {
|
||||||
|
colors: {
|
||||||
|
...sharedColors,
|
||||||
|
typography: "#000000",
|
||||||
|
background: "#ffffff",
|
||||||
|
primary: "#3B82F6",
|
||||||
|
},
|
||||||
|
margins: {
|
||||||
|
sm: 2,
|
||||||
|
md: 4,
|
||||||
|
lg: 8,
|
||||||
|
xl: 12,
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const darkTheme = {
|
||||||
|
colors: {
|
||||||
|
...sharedColors,
|
||||||
|
typography: "#ffffff",
|
||||||
|
background: "#000000",
|
||||||
|
primary: "#60A5FA",
|
||||||
|
},
|
||||||
|
margins: {
|
||||||
|
sm: 2,
|
||||||
|
md: 4,
|
||||||
|
lg: 8,
|
||||||
|
xl: 12,
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
12
apps/cli/templates/frontend/native/unistyles/tsconfig.json
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"extends": "expo/tsconfig.base",
|
||||||
|
"compilerOptions": {
|
||||||
|
"strict": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts"]
|
||||||
|
}
|
||||||
27
apps/cli/templates/frontend/native/unistyles/unistyles.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { StyleSheet } from 'react-native-unistyles';
|
||||||
|
|
||||||
|
import { breakpoints } from './breakpoints';
|
||||||
|
import { lightTheme, darkTheme } from './theme';
|
||||||
|
|
||||||
|
type AppBreakpoints = typeof breakpoints;
|
||||||
|
|
||||||
|
type AppThemes = {
|
||||||
|
light: typeof lightTheme;
|
||||||
|
dark: typeof darkTheme;
|
||||||
|
};
|
||||||
|
|
||||||
|
declare module 'react-native-unistyles' {
|
||||||
|
export interface UnistylesBreakpoints extends AppBreakpoints {}
|
||||||
|
export interface UnistylesThemes extends AppThemes {}
|
||||||
|
}
|
||||||
|
|
||||||
|
StyleSheet.configure({
|
||||||
|
breakpoints,
|
||||||
|
themes: {
|
||||||
|
light: lightTheme,
|
||||||
|
dark: darkTheme,
|
||||||
|
},
|
||||||
|
settings: {
|
||||||
|
adaptiveThemes: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -60,7 +60,6 @@ export default function Header() {
|
|||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
{{else}}
|
{{else}}
|
||||||
// Fallback case (shouldn't happen with valid frontend selection)
|
|
||||||
return null;
|
return null;
|
||||||
{{/if}}
|
{{/if}}
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -92,7 +92,9 @@ const hasWebFrontend = (frontend: string[]) =>
|
|||||||
].includes(f),
|
].includes(f),
|
||||||
);
|
);
|
||||||
|
|
||||||
const hasNativeFrontend = (frontend: string[]) => frontend.includes("native");
|
const checkHasNativeFrontend = (frontend: string[]) =>
|
||||||
|
frontend.includes("native-nativewind") ||
|
||||||
|
frontend.includes("native-unistyles");
|
||||||
|
|
||||||
const hasPWACompatibleFrontend = (frontend: string[]) =>
|
const hasPWACompatibleFrontend = (frontend: string[]) =>
|
||||||
frontend.some((f) =>
|
frontend.some((f) =>
|
||||||
@@ -183,6 +185,7 @@ const analyzeStackCompatibility = (stack: StackState): CompatibilityResult => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const isConvex = nextStack.backend === "convex";
|
const isConvex = nextStack.backend === "convex";
|
||||||
|
const isBackendNone = nextStack.backend === "none";
|
||||||
|
|
||||||
if (isConvex) {
|
if (isConvex) {
|
||||||
const convexOverrides: Partial<StackState> = {
|
const convexOverrides: Partial<StackState> = {
|
||||||
@@ -247,6 +250,40 @@ const analyzeStackCompatibility = (stack: StackState): CompatibilityResult => {
|
|||||||
message: "Frontend defaulted to TanStack Router",
|
message: "Frontend defaulted to TanStack Router",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
} else if (isBackendNone) {
|
||||||
|
const noneOverrides: Partial<StackState> = {
|
||||||
|
auth: "false",
|
||||||
|
database: "none",
|
||||||
|
orm: "none",
|
||||||
|
api: "none",
|
||||||
|
runtime: "none",
|
||||||
|
dbSetup: "none",
|
||||||
|
examples: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(noneOverrides)) {
|
||||||
|
const catKey = key as keyof StackState;
|
||||||
|
if (JSON.stringify(nextStack[catKey]) !== JSON.stringify(value)) {
|
||||||
|
const displayName = getCategoryDisplayName(catKey);
|
||||||
|
const valueDisplay = Array.isArray(value) ? "none" : value;
|
||||||
|
const message = `${displayName} set to '${valueDisplay}'`;
|
||||||
|
|
||||||
|
notes[catKey].notes.push(
|
||||||
|
`No backend selected: ${displayName} will be set to '${valueDisplay}'.`,
|
||||||
|
);
|
||||||
|
notes.backend.notes.push(
|
||||||
|
`No backend requires ${displayName} to be '${valueDisplay}'.`,
|
||||||
|
);
|
||||||
|
notes[catKey].hasIssue = true;
|
||||||
|
notes.backend.hasIssue = true;
|
||||||
|
(nextStack[catKey] as string | string[]) = value;
|
||||||
|
changed = true;
|
||||||
|
changes.push({
|
||||||
|
category: "backend-none",
|
||||||
|
message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
if (nextStack.runtime === "none") {
|
if (nextStack.runtime === "none") {
|
||||||
notes.runtime.notes.push(
|
notes.runtime.notes.push(
|
||||||
@@ -562,7 +599,7 @@ const analyzeStackCompatibility = (stack: StackState): CompatibilityResult => {
|
|||||||
|
|
||||||
const incompatibleExamples: string[] = [];
|
const incompatibleExamples: string[] = [];
|
||||||
const isWeb = hasWebFrontend(nextStack.frontend);
|
const isWeb = hasWebFrontend(nextStack.frontend);
|
||||||
const isNativeOnly = hasNativeFrontend(nextStack.frontend) && !isWeb;
|
const isNativeOnly = checkHasNativeFrontend(nextStack.frontend) && !isWeb;
|
||||||
|
|
||||||
if (isNativeOnly) {
|
if (isNativeOnly) {
|
||||||
if (nextStack.examples.length > 0) {
|
if (nextStack.examples.length > 0) {
|
||||||
@@ -694,17 +731,19 @@ const analyzeStackCompatibility = (stack: StackState): CompatibilityResult => {
|
|||||||
|
|
||||||
const getCompatibilityRules = (stack: StackState) => {
|
const getCompatibilityRules = (stack: StackState) => {
|
||||||
const isConvex = stack.backend === "convex";
|
const isConvex = stack.backend === "convex";
|
||||||
|
const isBackendNone = stack.backend === "none";
|
||||||
const hasWebFrontendSelected = hasWebFrontend(stack.frontend);
|
const hasWebFrontendSelected = hasWebFrontend(stack.frontend);
|
||||||
const hasNativeOnly =
|
const hasNativeFrontend = checkHasNativeFrontend(stack.frontend);
|
||||||
hasNativeFrontend(stack.frontend) && !hasWebFrontendSelected;
|
const hasNativeOnly = hasNativeFrontend && !hasWebFrontendSelected;
|
||||||
const hasSolid = stack.frontend.includes("solid");
|
const hasSolid = stack.frontend.includes("solid");
|
||||||
const hasNuxt = stack.frontend.includes("nuxt");
|
const hasNuxt = stack.frontend.includes("nuxt");
|
||||||
const hasSvelte = stack.frontend.includes("svelte");
|
const hasSvelte = stack.frontend.includes("svelte");
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isConvex,
|
isConvex,
|
||||||
|
isBackendNone,
|
||||||
hasWebFrontend: hasWebFrontendSelected,
|
hasWebFrontend: hasWebFrontendSelected,
|
||||||
hasNativeFrontend: hasNativeFrontend(stack.frontend),
|
hasNativeFrontend,
|
||||||
hasNativeOnly,
|
hasNativeOnly,
|
||||||
hasPWACompatible: hasPWACompatibleFrontend(stack.frontend),
|
hasPWACompatible: hasPWACompatibleFrontend(stack.frontend),
|
||||||
hasTauriCompatible: hasTauriCompatibleFrontend(stack.frontend),
|
hasTauriCompatible: hasTauriCompatibleFrontend(stack.frontend),
|
||||||
@@ -853,7 +892,7 @@ const StackArchitect = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
for (const category of CATEGORY_ORDER) {
|
for (const category of CATEGORY_ORDER) {
|
||||||
const options = TECH_OPTIONS[category] || [];
|
const options = TECH_OPTIONS[category as keyof typeof TECH_OPTIONS] || [];
|
||||||
const catKey = category as keyof StackState;
|
const catKey = category as keyof StackState;
|
||||||
|
|
||||||
for (const tech of options) {
|
for (const tech of options) {
|
||||||
@@ -879,7 +918,9 @@ const StackArchitect = () => {
|
|||||||
addRule(
|
addRule(
|
||||||
category,
|
category,
|
||||||
techId,
|
techId,
|
||||||
`Convex backend requires ${getCategoryDisplayName(catKey)} to be '${requiredValue}'.`,
|
`Convex backend requires ${getCategoryDisplayName(
|
||||||
|
catKey,
|
||||||
|
)} to be '${requiredValue}'.`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -897,12 +938,43 @@ const StackArchitect = () => {
|
|||||||
addRule(
|
addRule(
|
||||||
category,
|
category,
|
||||||
techId,
|
techId,
|
||||||
`${tech.name} is not compatible with Convex backend.`,
|
`Convex backend is not compatible with ${tech.name}.`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (rules.isBackendNone) {
|
||||||
|
if (
|
||||||
|
[
|
||||||
|
"auth",
|
||||||
|
"database",
|
||||||
|
"orm",
|
||||||
|
"api",
|
||||||
|
"runtime",
|
||||||
|
"dbSetup",
|
||||||
|
"examples",
|
||||||
|
].includes(catKey)
|
||||||
|
) {
|
||||||
|
if (
|
||||||
|
(catKey === "auth" && techId === "true") ||
|
||||||
|
((catKey === "database" ||
|
||||||
|
catKey === "orm" ||
|
||||||
|
catKey === "api" ||
|
||||||
|
catKey === "runtime" ||
|
||||||
|
catKey === "dbSetup") &&
|
||||||
|
techId !== "none") ||
|
||||||
|
(catKey === "examples" && techId !== "none")
|
||||||
|
) {
|
||||||
|
addRule(
|
||||||
|
category,
|
||||||
|
techId,
|
||||||
|
`Cannot be selected when 'No Backend' is chosen. Will be set to 'None' or disabled.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (catKey === "runtime" && techId === "none") {
|
if (catKey === "runtime" && techId === "none") {
|
||||||
addRule(
|
addRule(
|
||||||
category,
|
category,
|
||||||
@@ -934,114 +1006,68 @@ const StackArchitect = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (catKey === "orm") {
|
if (catKey === "orm") {
|
||||||
if (stack.database === "none" && techId !== "none") {
|
|
||||||
addRule(
|
|
||||||
category,
|
|
||||||
techId,
|
|
||||||
"Select a database to enable ORM options.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
stack.database === "mongodb" &&
|
|
||||||
techId !== "prisma" &&
|
|
||||||
techId !== "mongoose" &&
|
|
||||||
techId !== "none"
|
|
||||||
) {
|
|
||||||
addRule(
|
|
||||||
category,
|
|
||||||
techId,
|
|
||||||
"MongoDB requires the Prisma or Mongoose ORM.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
stack.database !== "mongodb" &&
|
|
||||||
stack.database !== "none" &&
|
|
||||||
techId === "mongoose"
|
|
||||||
) {
|
|
||||||
addRule(
|
|
||||||
category,
|
|
||||||
techId,
|
|
||||||
"Mongoose ORM is only compatible with MongoDB.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (stack.dbSetup === "turso" && techId !== "drizzle") {
|
|
||||||
addRule(category, techId, "Turso DB setup requires Drizzle ORM.");
|
|
||||||
}
|
|
||||||
if (stack.dbSetup === "prisma-postgres" && techId !== "prisma") {
|
|
||||||
addRule(
|
|
||||||
category,
|
|
||||||
techId,
|
|
||||||
"Prisma PostgreSQL setup requires Prisma ORM.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
stack.dbSetup === "mongodb-atlas" &&
|
|
||||||
techId !== "prisma" &&
|
|
||||||
techId !== "mongoose"
|
|
||||||
) {
|
|
||||||
addRule(
|
|
||||||
category,
|
|
||||||
techId,
|
|
||||||
"MongoDB Atlas setup requires Prisma or Mongoose ORM.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (techId === "none") {
|
if (techId === "none") {
|
||||||
if (stack.database === "mongodb") {
|
addRule(
|
||||||
addRule(
|
category,
|
||||||
category,
|
techId,
|
||||||
techId,
|
"ORM 'None' is only available with the Convex backend.",
|
||||||
"MongoDB requires Prisma or Mongoose ORM.",
|
);
|
||||||
);
|
|
||||||
}
|
|
||||||
if (stack.dbSetup === "turso") {
|
|
||||||
addRule(category, techId, "Turso DB setup requires Drizzle ORM.");
|
|
||||||
}
|
|
||||||
if (stack.dbSetup === "prisma-postgres") {
|
|
||||||
addRule(category, techId, "This DB setup requires Prisma ORM.");
|
|
||||||
}
|
|
||||||
if (stack.dbSetup === "mongodb-atlas") {
|
|
||||||
addRule(
|
|
||||||
category,
|
|
||||||
techId,
|
|
||||||
"This DB setup requires Prisma or Mongoose ORM.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (catKey === "dbSetup" && techId !== "none") {
|
if (catKey === "dbSetup" && techId !== "none") {
|
||||||
if (stack.database === "none") {
|
if (techId === "turso") {
|
||||||
addRule(
|
if (stack.database !== "sqlite") {
|
||||||
category,
|
addRule(
|
||||||
techId,
|
category,
|
||||||
"Select a database before choosing a cloud setup.",
|
techId,
|
||||||
);
|
"Turso requires SQLite. It will be selected.",
|
||||||
} else {
|
);
|
||||||
if (techId === "turso") {
|
}
|
||||||
if (stack.database !== "sqlite") {
|
if (stack.orm !== "drizzle") {
|
||||||
addRule(category, techId, "Turso requires SQLite database.");
|
addRule(
|
||||||
}
|
category,
|
||||||
if (stack.orm !== "drizzle") {
|
techId,
|
||||||
addRule(category, techId, "Turso requires Drizzle ORM.");
|
"Turso requires Drizzle ORM. It will be selected.",
|
||||||
}
|
);
|
||||||
} else if (techId === "prisma-postgres") {
|
}
|
||||||
if (stack.database !== "postgres") {
|
} else if (techId === "prisma-postgres") {
|
||||||
addRule(category, techId, "Requires PostgreSQL database.");
|
if (stack.database !== "postgres") {
|
||||||
}
|
addRule(
|
||||||
if (stack.orm !== "prisma") {
|
category,
|
||||||
addRule(category, techId, "Requires Prisma ORM.");
|
techId,
|
||||||
}
|
"Prisma PostgreSQL setup requires PostgreSQL. It will be selected.",
|
||||||
} else if (techId === "mongodb-atlas") {
|
);
|
||||||
if (stack.database !== "mongodb") {
|
}
|
||||||
addRule(category, techId, "Requires MongoDB database.");
|
if (stack.orm !== "prisma") {
|
||||||
}
|
addRule(
|
||||||
if (stack.orm !== "prisma" && stack.orm !== "mongoose") {
|
category,
|
||||||
addRule(category, techId, "Requires Prisma or Mongoose ORM.");
|
techId,
|
||||||
}
|
"Prisma PostgreSQL setup requires Prisma ORM. It will be selected.",
|
||||||
} else if (techId === "neon") {
|
);
|
||||||
if (stack.database !== "postgres") {
|
}
|
||||||
addRule(category, techId, "Requires PostgreSQL database.");
|
} else if (techId === "mongodb-atlas") {
|
||||||
}
|
if (stack.database !== "mongodb") {
|
||||||
|
addRule(
|
||||||
|
category,
|
||||||
|
techId,
|
||||||
|
"MongoDB Atlas setup requires MongoDB. It will be selected.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (stack.orm !== "prisma" && stack.orm !== "mongoose") {
|
||||||
|
addRule(
|
||||||
|
category,
|
||||||
|
techId,
|
||||||
|
"MongoDB Atlas setup requires Prisma or Mongoose ORM. Prisma will be selected.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else if (techId === "neon") {
|
||||||
|
if (stack.database !== "postgres") {
|
||||||
|
addRule(
|
||||||
|
category,
|
||||||
|
techId,
|
||||||
|
"Neon requires PostgreSQL. It will be selected.",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1055,56 +1081,164 @@ const StackArchitect = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (catKey === "addons") {
|
if (catKey === "addons") {
|
||||||
if (techId === "pwa" && !rules.hasPWACompatible) {
|
const incompatibleAddons: string[] = [];
|
||||||
|
const isPWACompat = hasPWACompatibleFrontend(stack.frontend);
|
||||||
|
const isTauriCompat = hasTauriCompatibleFrontend(stack.frontend);
|
||||||
|
|
||||||
|
if (!isPWACompat && stack.addons.includes("pwa")) {
|
||||||
|
incompatibleAddons.push("pwa");
|
||||||
addRule(
|
addRule(
|
||||||
category,
|
category,
|
||||||
techId,
|
techId,
|
||||||
"Requires TanStack Router, React Router or Solid frontend.",
|
"PWA addon removed (requires compatible frontend)",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (techId === "tauri" && !rules.hasTauriCompatible) {
|
if (!isTauriCompat && stack.addons.includes("tauri")) {
|
||||||
|
incompatibleAddons.push("tauri");
|
||||||
addRule(
|
addRule(
|
||||||
category,
|
category,
|
||||||
techId,
|
techId,
|
||||||
"Requires TanStack Router, React Router, Nuxt, Svelte or Solid frontend.",
|
"Tauri addon removed (requires compatible frontend)",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const originalAddonsLength = stack.addons.length;
|
||||||
|
if (incompatibleAddons.length > 0) {
|
||||||
|
stack.addons = stack.addons.filter(
|
||||||
|
(addon) => !incompatibleAddons.includes(addon),
|
||||||
|
);
|
||||||
|
if (stack.addons.length !== originalAddonsLength) {
|
||||||
|
addRule(
|
||||||
|
category,
|
||||||
|
techId,
|
||||||
|
"Addons filtered (requires compatible frontend)",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
stack.addons.includes("husky") &&
|
||||||
|
!stack.addons.includes("biome")
|
||||||
|
) {
|
||||||
|
addRule(
|
||||||
|
category,
|
||||||
|
techId,
|
||||||
|
"Husky addon is selected without Biome. Consider adding Biome for lint-staged integration.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (catKey === "examples") {
|
if (catKey === "examples") {
|
||||||
if (rules.hasNativeOnly) {
|
const incompatibleExamples: string[] = [];
|
||||||
addRule(
|
const isWeb = hasWebFrontend(stack.frontend);
|
||||||
category,
|
const isNativeOnly = checkHasNativeFrontend(stack.frontend) && !isWeb;
|
||||||
techId,
|
|
||||||
"Examples are not supported with Native-only frontend.",
|
if (isNativeOnly) {
|
||||||
);
|
if (stack.examples.length > 0) {
|
||||||
} else {
|
|
||||||
if (!rules.hasWebFrontend) {
|
|
||||||
addRule(
|
addRule(
|
||||||
category,
|
category,
|
||||||
techId,
|
techId,
|
||||||
"Requires a web frontend (TanStack Router, React Router, etc.).",
|
"Examples removed (not supported with Native-only frontend)",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (techId === "todo" && stack.database === "none") {
|
} else {
|
||||||
addRule(category, techId, "Todo example requires a database.");
|
if (!isWeb) {
|
||||||
|
if (stack.examples.includes("todo")) {
|
||||||
|
incompatibleExamples.push("todo");
|
||||||
|
addRule(
|
||||||
|
category,
|
||||||
|
techId,
|
||||||
|
"Todo example removed (requires web frontend)",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (stack.examples.includes("ai")) {
|
||||||
|
incompatibleExamples.push("ai");
|
||||||
|
addRule(
|
||||||
|
category,
|
||||||
|
techId,
|
||||||
|
"AI example removed (requires web frontend)",
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (techId === "ai") {
|
if (stack.database === "none" && stack.examples.includes("todo")) {
|
||||||
if (stack.backend === "elysia") {
|
incompatibleExamples.push("todo");
|
||||||
|
addRule(
|
||||||
|
category,
|
||||||
|
techId,
|
||||||
|
"Todo example removed (requires a database)",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (stack.backend === "elysia" && stack.examples.includes("ai")) {
|
||||||
|
incompatibleExamples.push("ai");
|
||||||
|
addRule(
|
||||||
|
category,
|
||||||
|
techId,
|
||||||
|
"AI example removed (not compatible with Elysia)",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (rules.hasSolid && stack.examples.includes("ai")) {
|
||||||
|
incompatibleExamples.push("ai");
|
||||||
|
addRule(
|
||||||
|
category,
|
||||||
|
techId,
|
||||||
|
"AI example removed (not compatible with Solid)",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const uniqueIncompatibleExamples = [...new Set(incompatibleExamples)];
|
||||||
|
if (uniqueIncompatibleExamples.length > 0) {
|
||||||
|
if (!isWeb && !isNativeOnly) {
|
||||||
|
if (
|
||||||
|
uniqueIncompatibleExamples.includes("todo") ||
|
||||||
|
uniqueIncompatibleExamples.includes("ai")
|
||||||
|
) {
|
||||||
addRule(
|
addRule(
|
||||||
category,
|
category,
|
||||||
techId,
|
techId,
|
||||||
"AI example is not compatible with Elysia backend.",
|
"Examples require a web frontend. Incompatible examples will be removed.",
|
||||||
);
|
|
||||||
}
|
|
||||||
if (rules.hasSolid) {
|
|
||||||
addRule(
|
|
||||||
category,
|
|
||||||
techId,
|
|
||||||
"AI example is not compatible with Solid frontend.",
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
stack.database === "none" &&
|
||||||
|
uniqueIncompatibleExamples.includes("todo")
|
||||||
|
) {
|
||||||
|
addRule(
|
||||||
|
category,
|
||||||
|
techId,
|
||||||
|
"Todo example requires a database. It will be removed.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
stack.backend === "elysia" &&
|
||||||
|
uniqueIncompatibleExamples.includes("ai")
|
||||||
|
) {
|
||||||
|
addRule(
|
||||||
|
category,
|
||||||
|
techId,
|
||||||
|
"AI example is not compatible with Elysia. It will be removed.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (rules.hasSolid && uniqueIncompatibleExamples.includes("ai")) {
|
||||||
|
addRule(
|
||||||
|
category,
|
||||||
|
techId,
|
||||||
|
"AI example is not compatible with Solid. It will be removed.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const originalExamplesLength = stack.examples.length;
|
||||||
|
stack.examples = stack.examples.filter(
|
||||||
|
(ex) => !uniqueIncompatibleExamples.includes(ex),
|
||||||
|
);
|
||||||
|
if (stack.examples.length !== originalExamplesLength) {
|
||||||
|
addRule(
|
||||||
|
category,
|
||||||
|
techId,
|
||||||
|
"Examples filtered (incompatible examples removed)",
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1256,6 +1390,8 @@ const StackArchitect = () => {
|
|||||||
nextArray = nextArray.filter((id) => id !== "none");
|
nextArray = nextArray.filter((id) => id !== "none");
|
||||||
if (webTypes.includes(techId)) {
|
if (webTypes.includes(techId)) {
|
||||||
nextArray = nextArray.filter((id) => !webTypes.includes(id));
|
nextArray = nextArray.filter((id) => !webTypes.includes(id));
|
||||||
|
} else if (techId.startsWith("native-")) {
|
||||||
|
nextArray = nextArray.filter((id) => !id.startsWith("native-"));
|
||||||
}
|
}
|
||||||
nextArray.push(techId);
|
nextArray.push(techId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,13 +75,21 @@ export const TECH_OPTIONS = {
|
|||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "native",
|
id: "native-nativewind",
|
||||||
name: "React Native",
|
name: "React Native + NativeWind",
|
||||||
description: "Expo with NativeWind",
|
description: "Expo with NativeWind (Tailwind)",
|
||||||
icon: "/icon/expo.svg",
|
icon: "/icon/expo.svg",
|
||||||
color: "from-purple-400 to-purple-600",
|
color: "from-purple-400 to-purple-600",
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "native-unistyles",
|
||||||
|
name: "React Native + Unistyles",
|
||||||
|
description: "Expo with Unistyles",
|
||||||
|
icon: "/icon/expo.svg",
|
||||||
|
color: "from-pink-400 to-pink-600",
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "none",
|
id: "none",
|
||||||
name: "No Frontend",
|
name: "No Frontend",
|
||||||
@@ -145,6 +153,13 @@ export const TECH_OPTIONS = {
|
|||||||
icon: "/icon/convex.svg",
|
icon: "/icon/convex.svg",
|
||||||
color: "from-pink-500 to-pink-700",
|
color: "from-pink-500 to-pink-700",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "none",
|
||||||
|
name: "No Backend",
|
||||||
|
description: "Skip backend integration (frontend only)",
|
||||||
|
icon: "⚙️",
|
||||||
|
color: "from-gray-400 to-gray-600",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
database: [
|
database: [
|
||||||
{
|
{
|
||||||
@@ -447,7 +462,7 @@ export const PRESET_TEMPLATES = [
|
|||||||
description: "React Native with Expo and SQLite database",
|
description: "React Native with Expo and SQLite database",
|
||||||
stack: {
|
stack: {
|
||||||
projectName: "my-better-t-app",
|
projectName: "my-better-t-app",
|
||||||
frontend: ["native"],
|
frontend: ["native-nativewind"],
|
||||||
runtime: "bun",
|
runtime: "bun",
|
||||||
backend: "hono",
|
backend: "hono",
|
||||||
database: "sqlite",
|
database: "sqlite",
|
||||||
@@ -489,7 +504,7 @@ export const PRESET_TEMPLATES = [
|
|||||||
description: "Complete setup with web, native, Turso, and addons",
|
description: "Complete setup with web, native, Turso, and addons",
|
||||||
stack: {
|
stack: {
|
||||||
projectName: "my-better-t-app",
|
projectName: "my-better-t-app",
|
||||||
frontend: ["tanstack-router", "native"],
|
frontend: ["tanstack-router", "native-nativewind"],
|
||||||
runtime: "bun",
|
runtime: "bun",
|
||||||
backend: "hono",
|
backend: "hono",
|
||||||
database: "sqlite",
|
database: "sqlite",
|
||||||
|
|||||||