Added support for building mobile applications with Expo

This commit is contained in:
Aman Varshney
2025-03-29 12:31:51 +05:30
parent 228f24d6db
commit 1c66d64be5
90 changed files with 981 additions and 204 deletions

View File

@@ -0,0 +1,5 @@
---
"create-better-t-stack": minor
---
Added support for building mobile applications with Expo

View File

@@ -20,10 +20,11 @@ pnpm create better-t-stack@latest
- ⚡️ **Zero-config setup** with interactive CLI wizard
- 🔄 **End-to-end type safety** from database to frontend via tRPC
- 🧱 **Modern stack** with React, Hono/Elysia, and TanStack libraries
- 📱 **Multi-platform** supporting web, mobile (Expo), and desktop applications
- 🗃️ **Database flexibility** with SQLite (Turso) or PostgreSQL options
- 🛠️ **ORM choice** between Drizzle or Prisma
- 🔒 **Built-in authentication** with Better-Auth
- 📱 **Optional PWA support** for mobile-friendly applications
- 📱 **Optional PWA support** for installable web applications
- 🖥️ **Desktop app capabilities** with Tauri integration
- 📦 **Monorepo architecture** powered by Turborepo

View File

@@ -1,6 +1,6 @@
# Create Better-T-Stack CLI
An interactive CLI tool to quickly scaffold full-stack TypeScript applications with a choice of modern backend frameworks (Hono or Elysia) and tRPC. The Better-T-Stack provides a type-safe development experience with the best tools from the TypeScript ecosystem.
A CLI tool for scaffolding type-safe full-stack apps with Hono/Elysia backends, React web frontends, and Expo native apps, all connected through tRPC.
## Quick Start
@@ -23,6 +23,7 @@ Follow the prompts to configure your project or use the `-y` flag for defaults.
- **Monorepo**: Turborepo for optimized build system and workspace management
- **Frontend**: React, TanStack Router, TanStack Query, Tailwind CSS with shadcn/ui components
- **Native Apps**: Create React Native apps with Expo for iOS and Android
- **Backend Frameworks**: Choose between Hono or Elysia
- **API Layer**: End-to-end type safety with tRPC
- **Runtime Options**: Choose between Bun or Node.js for your server
@@ -70,6 +71,10 @@ Options:
--hono Use Hono backend framework (default)
--elysia Use Elysia backend framework
--runtime <runtime> Specify runtime (bun or node)
--web Include web frontend (default)
--native Include Expo frontend
--no-web Exclude web frontend
--no-native Exclude Expo frontend
-h, --help Display help
```

View File

@@ -9,6 +9,7 @@ export const PKG_ROOT = path.join(distPath, "../");
export const DEFAULT_CONFIG: ProjectConfig = {
projectName: "my-better-t-app",
frontend: ["web"],
database: "sqlite",
orm: "drizzle",
auth: true,

View File

@@ -87,7 +87,7 @@ async function setupPwa(projectDir: string) {
await fs.copy(pwaTemplateDir, projectDir, { overwrite: true });
}
const clientPackageDir = path.join(projectDir, "apps/client");
const clientPackageDir = path.join(projectDir, "apps/web");
addPackageDependency({
dependencies: ["vite-plugin-pwa"],

View File

@@ -23,7 +23,7 @@ export async function setupAuth(
}
const serverDir = path.join(projectDir, "apps/server");
const clientDir = path.join(projectDir, "apps/client");
const clientDir = path.join(projectDir, "apps/web");
try {
addPackageDependency({

View File

@@ -18,6 +18,7 @@ import {
fixGitignoreFiles,
setupAuthTemplate,
setupBackendFramework,
setupFrontendTemplates,
setupOrmTemplate,
} from "./template-manager";
@@ -29,6 +30,8 @@ export async function createProject(options: ProjectConfig): Promise<string> {
await fs.ensureDir(projectDir);
await copyBaseTemplate(projectDir);
await setupFrontendTemplates(projectDir, options.frontend);
await fixGitignoreFiles(projectDir);
await setupBackendFramework(projectDir, options.backendFramework);
@@ -89,6 +92,7 @@ export async function createProject(options: ProjectConfig): Promise<string> {
options.orm,
options.addons,
options.runtime,
options.frontend,
);
return projectDir;

View File

@@ -57,7 +57,7 @@ Then, run the development server:
${packageManagerRunCmd} dev
\`\`\`
Open [http://localhost:3001](http://localhost:3001) in your browser to see the client application.
Open [http://localhost:3001](http://localhost:3001) in your browser to see the web application.
The API is running at [http://localhost:3000](http://localhost:3000).
## Project Structure
@@ -65,7 +65,7 @@ The API is running at [http://localhost:3000](http://localhost:3000).
\`\`\`
${projectName}/
├── apps/
│ ├── client/ # Frontend application (React, TanStack Router)
│ ├── web/ # Frontend application (React, TanStack Router)
│ └── server/ # Backend API (Hono, tRPC)
\`\`\`
@@ -173,9 +173,9 @@ function generateScriptsList(
orm: ProjectOrm,
auth: boolean,
): string {
let scripts = `- \`${packageManagerRunCmd} dev\`: Start both client and server in development mode
- \`${packageManagerRunCmd} build\`: Build both client and server
- \`${packageManagerRunCmd} dev:client\`: Start only the client
let scripts = `- \`${packageManagerRunCmd} dev\`: Start both web and server in development mode
- \`${packageManagerRunCmd} build\`: Build both web and server
- \`${packageManagerRunCmd} dev:web\`: Start only the web application
- \`${packageManagerRunCmd} dev:server\`: Start only the server
- \`${packageManagerRunCmd} check-types\`: Check TypeScript types across all apps`;

View File

@@ -8,7 +8,6 @@ export async function setupEnvironmentVariables(
options: ProjectConfig,
): Promise<void> {
const serverDir = path.join(projectDir, "apps/server");
const clientDir = path.join(projectDir, "apps/client");
const envPath = path.join(serverDir, ".env");
let envContent = "";
@@ -49,16 +48,35 @@ export async function setupEnvironmentVariables(
await fs.writeFile(envPath, envContent.trim());
const clientEnvPath = path.join(clientDir, ".env");
let clientEnvContent = "";
if (options.frontend.includes("web")) {
const clientDir = path.join(projectDir, "apps/web");
const clientEnvPath = path.join(clientDir, ".env");
let clientEnvContent = "";
if (await fs.pathExists(clientEnvPath)) {
clientEnvContent = await fs.readFile(clientEnvPath, "utf8");
if (await fs.pathExists(clientEnvPath)) {
clientEnvContent = await fs.readFile(clientEnvPath, "utf8");
}
if (!clientEnvContent.includes("VITE_SERVER_URL")) {
clientEnvContent += "VITE_SERVER_URL=http://localhost:3000\n";
}
await fs.writeFile(clientEnvPath, clientEnvContent.trim());
}
if (!clientEnvContent.includes("VITE_SERVER_URL")) {
clientEnvContent += "VITE_SERVER_URL=http://localhost:3000\n";
}
if (options.frontend.includes("native")) {
const nativeDir = path.join(projectDir, "apps/native");
const nativeEnvPath = path.join(nativeDir, ".env");
let nativeEnvContent = "";
await fs.writeFile(clientEnvPath, clientEnvContent.trim());
if (await fs.pathExists(nativeEnvPath)) {
nativeEnvContent = await fs.readFile(nativeEnvPath, "utf8");
}
if (!nativeEnvContent.includes("EXPO_PUBLIC_SERVER_URL")) {
nativeEnvContent += "EXPO_PUBLIC_SERVER_URL=http://localhost:3000\n";
}
await fs.writeFile(nativeEnvPath, nativeEnvContent.trim());
}
}

View File

@@ -23,8 +23,8 @@ async function setupTodoExample(
): Promise<void> {
const todoExampleDir = path.join(PKG_ROOT, "template/examples/todo");
if (await fs.pathExists(todoExampleDir)) {
const todoRouteDir = path.join(todoExampleDir, "apps/client/src/routes");
const targetRouteDir = path.join(projectDir, "apps/client/src/routes");
const todoRouteDir = path.join(todoExampleDir, "apps/web/src/routes");
const targetRouteDir = path.join(projectDir, "apps/web/src/routes");
await fs.copy(todoRouteDir, targetRouteDir, { overwrite: true });
if (orm !== "none") {
@@ -55,7 +55,7 @@ async function updateHeaderWithTodoLink(
): Promise<void> {
const headerPath = path.join(
projectDir,
"apps/client/src/components/header.tsx",
"apps/web/src/components/header.tsx",
);
if (await fs.pathExists(headerPath)) {
@@ -125,7 +125,7 @@ async function updateRouterIndex(projectDir: string): Promise<void> {
}
async function addTodoButtonToHomepage(projectDir: string): Promise<void> {
const indexPath = path.join(projectDir, "apps/client/src/routes/index.tsx");
const indexPath = path.join(projectDir, "apps/web/src/routes/index.tsx");
if (await fs.pathExists(indexPath)) {
let indexContent = await fs.readFile(indexPath, "utf8");

View File

@@ -4,6 +4,7 @@ import type {
PackageManager,
ProjectAddons,
ProjectDatabase,
ProjectFrontend,
ProjectOrm,
Runtime,
} from "../types";
@@ -13,9 +14,10 @@ export function displayPostInstallInstructions(
projectName: string,
packageManager: PackageManager,
depsInstalled: boolean,
orm?: ProjectOrm,
addons?: ProjectAddons[],
runtime?: Runtime,
orm: ProjectOrm,
addons: ProjectAddons[],
runtime: Runtime,
frontends: ProjectFrontend[],
) {
const runCmd = packageManager === "npm" ? "npm run" : packageManager;
const cdCmd = `cd ${projectName}`;
@@ -32,15 +34,29 @@ export function displayPostInstallInstructions(
const lintingInstructions = hasHuskyOrBiome
? getLintingInstructions(runCmd)
: "";
const nativeInstructions = frontends?.includes("native")
? getNativeInstructions()
: "";
const hasWebFrontend = frontends?.includes("web");
const hasNativeFrontend = frontends?.includes("native");
const hasFrontend = hasWebFrontend || hasNativeFrontend;
log.info(`${pc.bold("Next steps:")}
${pc.cyan("1.")} ${cdCmd}
${!depsInstalled ? `${pc.cyan("2.")} ${packageManager} install\n` : ""}${pc.cyan(depsInstalled ? "2." : "3.")} ${runCmd} dev
${pc.bold("Your project will be available at:")}
${pc.cyan("•")} Frontend: http://localhost:3001
${pc.cyan("•")} API: http://localhost:3000
${databaseInstructions ? `\n${databaseInstructions.trim()}` : ""}${tauriInstructions ? `\n${tauriInstructions.trim()}` : ""}${lintingInstructions ? `\n${lintingInstructions.trim()}` : ""}`);
${
hasFrontend
? `${hasWebFrontend ? `${pc.cyan("•")} Frontend: http://localhost:3001\n` : ""}`
: `${pc.yellow("NOTE:")} You are creating a backend-only app (no frontend selected)\n`
}${pc.cyan("•")} API: http://localhost:3000
${nativeInstructions ? `\n${nativeInstructions.trim()}` : ""}${databaseInstructions ? `\n${databaseInstructions.trim()}` : ""}${tauriInstructions ? `\n${tauriInstructions.trim()}` : ""}${lintingInstructions ? `\n${lintingInstructions.trim()}` : ""}`);
}
function getNativeInstructions(): string {
return `${pc.yellow("NOTE:")} If the Expo app cannot connect to the server, update the EXPO_PUBLIC_SERVER_URL in apps/native/.env to use your local IP address instead of localhost:\n${pc.dim("EXPO_PUBLIC_SERVER_URL=http://192.168.0.103:3000")}\n`;
}
function getLintingInstructions(runCmd?: string): string {
@@ -95,5 +111,5 @@ function getDatabaseInstructions(
}
function getTauriInstructions(runCmd?: string): string {
return `${pc.bold("Desktop app with Tauri:")}\n${pc.cyan("•")} Start desktop app: ${pc.dim(`cd apps/client && ${runCmd} desktop:dev`)}\n${pc.cyan("•")} Build desktop app: ${pc.dim(`cd apps/client && ${runCmd} desktop:build`)}\n${pc.yellow("NOTE:")} Tauri requires Rust and platform-specific dependencies. See: ${pc.dim("https://v2.tauri.app/start/prerequisites/")}\n\n`;
return `${pc.bold("Desktop app with Tauri:")}\n${pc.cyan("•")} Start desktop app: ${pc.dim(`cd apps/web && ${runCmd} desktop:dev`)}\n${pc.cyan("•")} Build desktop app: ${pc.dim(`cd apps/web && ${runCmd} desktop:build`)}\n${pc.yellow("NOTE:")} Tauri requires Rust and platform-specific dependencies. See: ${pc.dim("https://v2.tauri.app/start/prerequisites/")}\n\n`;
}

View File

@@ -11,7 +11,7 @@ export async function setupTauri(
packageManager: PackageManager,
): Promise<void> {
const s = spinner();
const clientPackageDir = path.join(projectDir, "apps/client");
const clientPackageDir = path.join(projectDir, "apps/web");
try {
s.start("Setting up Tauri desktop app support...");

View File

@@ -1,7 +1,12 @@
import path from "node:path";
import fs from "fs-extra";
import { PKG_ROOT } from "../constants";
import type { BackendFramework, ProjectDatabase, ProjectOrm } from "../types";
import type {
BackendFramework,
ProjectDatabase,
ProjectFrontend,
ProjectOrm,
} from "../types";
export async function copyBaseTemplate(projectDir: string): Promise<void> {
const templateDir = path.join(PKG_ROOT, "template/base");
@@ -11,6 +16,30 @@ export async function copyBaseTemplate(projectDir: string): Promise<void> {
await fs.copy(templateDir, projectDir);
}
export async function setupFrontendTemplates(
projectDir: string,
frontends: ProjectFrontend[],
): Promise<void> {
if (!frontends.includes("web")) {
const webDir = path.join(projectDir, "apps/web");
if (await fs.pathExists(webDir)) {
await fs.remove(webDir);
}
}
if (!frontends.includes("native")) {
const nativeDir = path.join(projectDir, "apps/native");
if (await fs.pathExists(nativeDir)) {
await fs.remove(nativeDir);
}
} else {
await fs.writeFile(
path.join(projectDir, ".npmrc"),
"node-linker=hoisted\n",
);
}
}
export async function setupBackendFramework(
projectDir: string,
framework: BackendFramework,
@@ -67,8 +96,8 @@ export async function setupAuthTemplate(
const authTemplateDir = path.join(PKG_ROOT, "template/with-auth");
if (await fs.pathExists(authTemplateDir)) {
const clientAuthDir = path.join(authTemplateDir, "apps/client");
const projectClientDir = path.join(projectDir, "apps/client");
const clientAuthDir = path.join(authTemplateDir, "apps/web");
const projectClientDir = path.join(projectDir, "apps/web");
await fs.copy(clientAuthDir, projectClientDir, { overwrite: true });
const serverAuthDir = path.join(authTemplateDir, "apps/server/src");
@@ -118,7 +147,8 @@ export async function setupAuthTemplate(
export async function fixGitignoreFiles(projectDir: string): Promise<void> {
const gitignorePaths = [
path.join(projectDir, "_gitignore"),
path.join(projectDir, "apps/client/_gitignore"),
path.join(projectDir, "apps/web/_gitignore"),
path.join(projectDir, "apps/native/_gitignore"),
path.join(projectDir, "apps/server/_gitignore"),
];

View File

@@ -10,6 +10,7 @@ import type {
ProjectAddons,
ProjectConfig,
ProjectExamples,
ProjectFrontend,
Runtime,
} from "./types";
import { displayConfig } from "./utils/display-config";
@@ -59,6 +60,10 @@ async function main() {
.option("--hono", "Use Hono backend framework")
.option("--elysia", "Use Elysia backend framework")
.option("--runtime <runtime>", "Specify runtime (bun or node)")
.option("--web", "Include web frontend")
.option("--native", "Include Expo frontend")
.option("--no-web", "Exclude web frontend")
.option("--no-native", "Exclude Expo frontend")
.parse();
const s = spinner();
@@ -115,6 +120,16 @@ async function main() {
.filter((e) => e === "todo") as ProjectExamples[])
: [],
}),
...((options.web !== undefined || options.native !== undefined) && {
frontend: [
...(options.web === false ? [] : options.web === true ? ["web"] : []),
...(options.native === false
? []
: options.native === true
? ["native"]
: []),
] as ProjectFrontend[],
}),
};
if (!options.yes && Object.keys(flagConfig).length > 0) {

View File

@@ -1,12 +1,26 @@
import { cancel, confirm, isCancel } from "@clack/prompts";
import { cancel, confirm, isCancel, log } from "@clack/prompts";
import pc from "picocolors";
import { DEFAULT_CONFIG } from "../constants";
import type { ProjectFrontend } from "../types";
export async function getAuthChoice(
auth: boolean | undefined,
hasDatabase: boolean,
frontends?: ProjectFrontend[],
): Promise<boolean> {
if (!hasDatabase) return false;
const hasNative = frontends?.includes("native");
const hasWeb = frontends?.includes("web");
if (hasNative) {
log.warn(
pc.yellow("Note: Authentication is not yet available with native"),
);
}
if (!hasWeb) return false;
if (auth !== undefined) return auth;
const response = await confirm({

View File

@@ -7,6 +7,7 @@ import type {
ProjectConfig,
ProjectDatabase,
ProjectExamples,
ProjectFrontend,
ProjectOrm,
Runtime,
} from "../types";
@@ -15,6 +16,7 @@ import { getAuthChoice } from "./auth";
import { getBackendFrameworkChoice } from "./backend-framework";
import { getDatabaseChoice } from "./database";
import { getExamplesChoice } from "./examples";
import { getFrontendChoice } from "./frontend-option";
import { getGitChoice } from "./git";
import { getNoInstallChoice } from "./install";
import { getORMChoice } from "./orm";
@@ -36,6 +38,7 @@ type PromptGroupResults = {
turso: boolean;
backendFramework: BackendFramework;
runtime: Runtime;
frontend: ProjectFrontend[];
};
export async function gatherConfig(
@@ -46,13 +49,18 @@ export async function gatherConfig(
projectName: async () => {
return getProjectName(flags.projectName);
},
frontend: () => getFrontendChoice(flags.frontend),
backendFramework: () => getBackendFrameworkChoice(flags.backendFramework),
runtime: () => getRuntimeChoice(flags.runtime),
database: () => getDatabaseChoice(flags.database),
orm: ({ results }) =>
getORMChoice(flags.orm, results.database !== "none"),
auth: ({ results }) =>
getAuthChoice(flags.auth, results.database !== "none"),
getAuthChoice(
flags.auth,
results.database !== "none",
results.frontend,
),
turso: ({ results }) =>
results.database === "sqlite" && results.orm !== "prisma"
? getTursoSetupChoice(flags.turso)
@@ -74,6 +82,7 @@ export async function gatherConfig(
return {
projectName: result.projectName,
frontend: result.frontend,
database: result.database,
orm: result.orm,
auth: result.auth,

View File

@@ -0,0 +1,35 @@
import { cancel, isCancel, multiselect } from "@clack/prompts";
import pc from "picocolors";
import { DEFAULT_CONFIG } from "../constants";
import type { ProjectFrontend } from "../types";
export async function getFrontendChoice(
frontendOptions?: ProjectFrontend[],
): Promise<ProjectFrontend[]> {
if (frontendOptions !== undefined) return frontendOptions;
const response = await multiselect<ProjectFrontend>({
message: "Which frontend applications would you like to create?",
options: [
{
value: "web",
label: "Web App",
hint: "React + TanStack Router web application",
},
{
value: "native",
label: "Native App",
hint: "React Native + Expo application",
},
],
initialValues: DEFAULT_CONFIG.frontend,
required: false,
});
if (isCancel(response)) {
cancel(pc.red("Operation cancelled"));
process.exit(0);
}
return response;
}

View File

@@ -21,7 +21,7 @@ export async function getORMChoice(
{
value: "prisma",
label: "Prisma",
hint: "Powerful, feature-rich ORM with schema migrations",
hint: "Powerful, feature-rich ORM",
},
],
initialValue: DEFAULT_CONFIG.orm,

View File

@@ -5,6 +5,7 @@ export type ProjectAddons = "pwa" | "biome" | "tauri" | "husky";
export type BackendFramework = "hono" | "elysia";
export type Runtime = "node" | "bun";
export type ProjectExamples = "todo";
export type ProjectFrontend = "web" | "native";
export interface ProjectConfig {
projectName: string;
@@ -19,4 +20,5 @@ export interface ProjectConfig {
packageManager: PackageManager;
noInstall?: boolean;
turso?: boolean;
frontend: ProjectFrontend[];
}

View File

@@ -35,6 +35,20 @@ export function generateReproducibleCommand(config: ProjectConfig): string {
flags.push(`--runtime ${config.runtime}`);
}
if (config.frontend) {
if (config.frontend.includes("web")) {
flags.push("--web");
} else {
flags.push("--no-web");
}
if (config.frontend.includes("native")) {
flags.push("--native");
} else {
flags.push("--no-native");
}
}
if (config.addons.length > 0) {
for (const addon of config.addons) {
flags.push(`--${addon}`);

View File

@@ -1,72 +0,0 @@
import {
QueryCache,
QueryClient,
QueryClientProvider,
} from "@tanstack/react-query";
import { RouterProvider, createRouter } from "@tanstack/react-router";
import { httpBatchLink } from "@trpc/client";
import { createTRPCQueryUtils } from "@trpc/react-query";
import ReactDOM from "react-dom/client";
import { toast } from "sonner";
import Loader from "./components/loader";
import { routeTree } from "./routeTree.gen";
import { trpc } from "./utils/trpc";
const queryClient = new QueryClient({
queryCache: new QueryCache({
onError: (error) => {
toast.error(error.message, {
action: {
label: "retry",
onClick: () => {
queryClient.invalidateQueries();
},
},
});
},
}),
});
const trpcClient = trpc.createClient({
links: [
httpBatchLink({
url: `${import.meta.env.VITE_SERVER_URL}/trpc`,
}),
],
});
export const trpcQueryUtils = createTRPCQueryUtils({
queryClient,
client: trpcClient,
});
const router = createRouter({
routeTree,
defaultPreload: "intent",
context: { trpcQueryUtils },
defaultPendingComponent: () => <Loader />,
Wrap: function WrapComponent({ children }) {
return (
<trpc.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
</trpc.Provider>
);
},
});
// Register things for typesafety
declare module "@tanstack/react-router" {
interface Register {
router: typeof router;
}
}
const rootElement = document.getElementById("app");
if (!rootElement) throw new Error("Root element not found");
if (!rootElement.innerHTML) {
const root = ReactDOM.createRoot(rootElement);
root.render(<RouterProvider router={router} />);
}

View File

@@ -1,4 +0,0 @@
import { createTRPCReact } from "@trpc/react-query";
import type { AppRouter } from "../../../server/src/routers";
export const trpc = createTRPCReact<AppRouter>();

View 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*

View File

@@ -0,0 +1,2 @@
// @ts-ignore
/// <reference types="nativewind/types" />

View File

@@ -0,0 +1,38 @@
{
"expo": {
"name": "my-better-t-app",
"slug": "my-better-t-app",
"version": "1.0.0",
"scheme": "my-better-t-app",
"web": {
"bundler": "metro",
"output": "static",
"favicon": "./assets/favicon.png"
},
"plugins": ["expo-router"],
"experiments": {
"typedRoutes": true,
"tsconfigPaths": true
},
"orientation": "portrait",
"icon": "./assets/icon.png",
"userInterfaceStyle": "light",
"splash": {
"image": "./assets/splash.png",
"resizeMode": "contain",
"backgroundColor": "#ffffff"
},
"assetBundlePatterns": ["**/*"],
"ios": {
"supportsTablet": true,
"bundleIdentifier": "com.amanvarshney01.mybettertapp"
},
"android": {
"adaptiveIcon": {
"foregroundImage": "./assets/adaptive-icon.png",
"backgroundColor": "#ffffff"
},
"package": "com.amanvarshney01.mybettertapp"
}
}
}

View File

@@ -0,0 +1,29 @@
import { Tabs } from "expo-router";
import { TabBarIcon } from "@/components/tabbar-icon";
export default function TabLayout() {
return (
<Tabs
screenOptions={{
headerShown: false,
tabBarActiveTintColor: "black",
}}
>
<Tabs.Screen
name="index"
options={{
title: "Tab One",
tabBarIcon: ({ color }) => <TabBarIcon name="code" color={color} />,
}}
/>
<Tabs.Screen
name="two"
options={{
title: "Tab Two",
tabBarIcon: ({ color }) => <TabBarIcon name="code" color={color} />,
}}
/>
</Tabs>
);
}

View File

@@ -0,0 +1,9 @@
import { View, Text } from "react-native";
export default function App() {
return (
<View>
<Text>Hello, World!</Text>
</View>
);
}

View File

@@ -0,0 +1,16 @@
import { Stack } from "expo-router";
import { View, Text } from "react-native";
import { Container } from "@/components/container";
export default function Home() {
return (
<>
<Container>
<View>
<Text>Tab Two</Text>
</View>
</Container>
</>
);
}

View File

@@ -0,0 +1,39 @@
import { Ionicons, MaterialIcons } from "@expo/vector-icons";
import { Link } from "expo-router";
import { Drawer } from "expo-router/drawer";
import { HeaderButton } from "@/components/header-button";
const DrawerLayout = () => {
return (
<Drawer>
<Drawer.Screen
name="index"
options={{
headerTitle: "Home",
drawerLabel: "Home",
drawerIcon: ({ size, color }) => (
<Ionicons name="home-outline" size={size} color={color} />
),
}}
/>
<Drawer.Screen
name="(tabs)"
options={{
headerTitle: "Tabs",
drawerLabel: "Tabs",
drawerIcon: ({ size, color }) => (
<MaterialIcons name="border-bottom" size={size} color={color} />
),
headerRight: () => (
<Link href="/modal" asChild>
<HeaderButton />
</Link>
),
}}
/>
</Drawer>
);
};
export default DrawerLayout;

View File

@@ -0,0 +1,36 @@
import { useQuery } from "@tanstack/react-query";
import { View, Text, ScrollView } from "react-native";
import { Container } from "@/components/container";
import { trpc } from "@/utils/trpc";
export default function Home() {
const healthCheck = useQuery(trpc.healthCheck.queryOptions());
return (
<Container>
<ScrollView className="py-4 flex-1">
<Text className="font-mono text-foreground text-2xl font-bold mb-6">
BETTER T STACK
</Text>
<View className="rounded-lg border border-foreground p-4">
<Text className="mb-2 font-medium text-foreground">API Status</Text>
<View className="flex-row items-center gap-2">
<View
className={`h-2.5 w-2.5 rounded-full ${
healthCheck.data ? "bg-green-500" : "bg-red-500"
}`}
/>
<Text className="text-sm text-foreground">
{healthCheck.isLoading
? "Checking..."
: healthCheck.data
? "Connected"
: "Disconnected"}
</Text>
</View>
</View>
</ScrollView>
</Container>
);
}

View File

@@ -0,0 +1,46 @@
import { ScrollViewStyleReset } from 'expo-router/html';
// This file is web-only and used to configure the root HTML for every
// web page during static rendering.
// The contents of this function only run in Node.js environments and
// do not have access to the DOM or browser APIs.
export default function Root({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta httpEquiv="X-UA-Compatible" content="IE=edge" />
{/*
This viewport disables scaling which makes the mobile website act more like a native app.
However this does reduce built-in accessibility. If you want to enable scaling, use this instead:
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
*/}
<meta
name="viewport"
content="width=device-width,initial-scale=1,minimum-scale=1,maximum-scale=1.00001,viewport-fit=cover"
/>
{/*
Disable body scrolling on web. This makes ScrollView components work closer to how they do on native.
However, body scrolling is often nice to have for mobile web. If you want to enable it, remove this line.
*/}
<ScrollViewStyleReset />
{/* Using raw CSS styles as an escape-hatch to ensure the background color never flickers in dark-mode. */}
<style dangerouslySetInnerHTML={{ __html: responsiveBackground }} />
{/* Add any additional <head> elements that you want globally available on web... */}
</head>
<body>{children}</body>
</html>
);
}
const responsiveBackground = `
body {
background-color: #fff;
}
@media (prefers-color-scheme: dark) {
body {
background-color: #000;
}
}`;

View File

@@ -0,0 +1,18 @@
import { Link, Stack } from 'expo-router';
import { Text } from 'react-native';
import { Container } from '@/components/container';
export default function NotFoundScreen() {
return (
<>
<Stack.Screen options={{ title: 'Oops!' }} />
<Container>
<Text className="text-xl font-bold">This screen doesn't exist.</Text>
<Link href="/" className="mt-4 pt-4">
<Text className="text-base text-[#2e78b7]">Go to home screen!</Text>
</Link>
</Container>
</>
);
}

View File

@@ -0,0 +1,76 @@
import { QueryClientProvider } from "@tanstack/react-query";
import { Stack } from "expo-router";
import {
DarkTheme,
DefaultTheme,
Theme,
ThemeProvider,
} from "@react-navigation/native";
import { StatusBar } from "expo-status-bar";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import "../global.css";
import { queryClient } from "@/utils/trpc";
import { NAV_THEME } from "@/lib/constants";
import React, { useRef } from "react";
import { useColorScheme } from "@/lib/use-color-scheme";
import { Platform } from "react-native";
import { setAndroidNavigationBar } from "@/lib/android-navigation-bar";
const LIGHT_THEME: Theme = {
...DefaultTheme,
colors: NAV_THEME.light,
};
const DARK_THEME: Theme = {
...DarkTheme,
colors: NAV_THEME.dark,
};
export const unstable_settings = {
// Ensure that reloading on `/modal` keeps a back button present.
initialRouteName: "(drawer)",
};
export default function RootLayout() {
const hasMounted = useRef(false);
const { colorScheme, isDarkColorScheme } = useColorScheme();
const [isColorSchemeLoaded, setIsColorSchemeLoaded] = React.useState(false);
useIsomorphicLayoutEffect(() => {
if (hasMounted.current) {
return;
}
if (Platform.OS === "web") {
// Adds the background color to the html element to prevent white background on overscroll.
document.documentElement.classList.add("bg-background");
}
setAndroidNavigationBar(colorScheme);
setIsColorSchemeLoaded(true);
hasMounted.current = true;
}, []);
if (!isColorSchemeLoaded) {
return null;
}
return (
<QueryClientProvider client={queryClient}>
<ThemeProvider value={isDarkColorScheme ? DARK_THEME : LIGHT_THEME}>
<StatusBar style={isDarkColorScheme ? "light" : "dark"} />
<GestureHandlerRootView style={{ flex: 1 }}>
<Stack>
<Stack.Screen name="(drawer)" options={{ headerShown: false }} />
<Stack.Screen
name="modal"
options={{ title: "Modal", presentation: "modal" }}
/>
</Stack>
</GestureHandlerRootView>
</ThemeProvider>
</QueryClientProvider>
);
}
const useIsomorphicLayoutEffect =
Platform.OS === "web" && typeof window === "undefined"
? React.useEffect
: React.useLayoutEffect;

View File

@@ -0,0 +1,15 @@
import { Text, View } from "react-native";
import { Container } from "@/components/container";
export default function Modal() {
return (
<>
<Container>
<View>
<Text>HI MODAL</Text>
</View>
</Container>
</>
);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

View File

@@ -0,0 +1,12 @@
module.exports = function (api) {
api.cache(true);
const plugins = [];
plugins.push('react-native-reanimated/plugin');
return {
presets: [['babel-preset-expo', { jsxImportSource: 'nativewind' }], 'nativewind/babel'],
plugins,
};
};

View File

@@ -0,0 +1,9 @@
import { SafeAreaView } from "react-native";
export const Container = ({ children }: { children: React.ReactNode }) => {
return (
<SafeAreaView className="flex flex-1 p-4 bg-background">
{children}
</SafeAreaView>
);
};

View File

@@ -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,
},
});

View File

@@ -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,
},
});

View File

@@ -0,0 +1,25 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 240 10% 3.9%;
--primary: 240 5.9% 10%;
--primary-foreground: 0 0% 98%;
--secondary: 240 4.8% 95.9%;
--secondary-foreground: 240 5.9% 10%;
--destructive: 0 84.2% 60.2%;
}
.dark:root {
--background: 240 10% 3.9%;
--foreground: 0 0% 98%;
--primary: 0 0% 98%;
--primary-foreground: 240 5.9% 10%;
--secondary: 240 3.7% 15.9%;
--secondary-foreground: 0 0% 98%;
--destructive: 0 72% 51%;
}
}

View File

@@ -0,0 +1,11 @@
import * as NavigationBar from "expo-navigation-bar";
import { Platform } from "react-native";
import { NAV_THEME } from "@/lib/constants";
export async function setAndroidNavigationBar(theme: "light" | "dark") {
if (Platform.OS !== "android") return;
await NavigationBar.setButtonStyleAsync(theme === "dark" ? "light" : "dark");
await NavigationBar.setBackgroundColorAsync(
theme === "dark" ? NAV_THEME.dark.background : NAV_THEME.light.background,
);
}

View File

@@ -0,0 +1,18 @@
export const NAV_THEME = {
light: {
background: "hsl(0 0% 100%)", // background
border: "hsl(240 5.9% 90%)", // border
card: "hsl(0 0% 100%)", // card
notification: "hsl(0 84.2% 60.2%)", // destructive
primary: "hsl(240 5.9% 10%)", // primary
text: "hsl(240 10% 3.9%)", // foreground
},
dark: {
background: "hsl(240 10% 3.9%)", // background
border: "hsl(240 3.7% 15.9%)", // border
card: "hsl(240 10% 3.9%)", // card
notification: "hsl(0 72% 51%)", // destructive
primary: "hsl(0 0% 98%)", // primary
text: "hsl(0 0% 98%)", // foreground
},
};

View File

@@ -0,0 +1,12 @@
import { useColorScheme as useNativewindColorScheme } from "nativewind";
export function useColorScheme() {
const { colorScheme, setColorScheme, toggleColorScheme } =
useNativewindColorScheme();
return {
colorScheme: colorScheme ?? "dark",
isDarkColorScheme: colorScheme === "dark",
setColorScheme,
toggleColorScheme,
};
}

View File

@@ -0,0 +1,57 @@
// Learn more https://docs.expo.io/guides/customizing-metro
const { getDefaultConfig } = require("expo/metro-config");
const { FileStore } = require("metro-cache");
const { withNativeWind } = require("nativewind/metro");
const path = require("path");
const config = withTurborepoManagedCache(
withMonorepoPaths(
withNativeWind(getDefaultConfig(__dirname), {
input: "./global.css",
configPath: "./tailwind.config.js",
}),
),
);
config.resolver.unstable_enablePackageExports = true;
module.exports = config;
/**
* Add the monorepo paths to the Metro config.
* This allows Metro to resolve modules from the monorepo.
*
* @see https://docs.expo.dev/guides/monorepos/#modify-the-metro-config
* @param {import('expo/metro-config').MetroConfig} config
* @returns {import('expo/metro-config').MetroConfig}
*/
function withMonorepoPaths(config) {
const projectRoot = __dirname;
const workspaceRoot = path.resolve(projectRoot, "../..");
// #1 - Watch all files in the monorepo
config.watchFolders = [workspaceRoot];
// #2 - Resolve modules within the project's `node_modules` first, then all monorepo modules
config.resolver.nodeModulesPaths = [
path.resolve(projectRoot, "node_modules"),
path.resolve(workspaceRoot, "node_modules"),
];
return config;
}
/**
* Move the Metro cache to the `.cache/metro` folder.
* If you have any environment variables, you can configure Turborepo to invalidate it when needed.
*
* @see https://turbo.build/repo/docs/reference/configuration#env
* @param {import('expo/metro-config').MetroConfig} config
* @returns {import('expo/metro-config').MetroConfig}
*/
function withTurborepoManagedCache(config) {
config.cacheStores = [
new FileStore({ root: path.join(__dirname, ".cache/metro") }),
];
return config;
}

View File

@@ -0,0 +1,47 @@
{
"name": "native",
"version": "1.0.0",
"main": "expo-router/entry",
"scripts": {
"dev": "expo start",
"android": "expo run:android",
"ios": "expo run:ios",
"prebuild": "expo prebuild",
"web": "expo start --web"
},
"dependencies": {
"@expo/vector-icons": "^14.0.4",
"@react-navigation/bottom-tabs": "^7.2.0",
"@react-navigation/drawer": "^7.1.1",
"@react-navigation/native": "^7.0.14",
"@tanstack/react-form": "^1.0.5",
"@tanstack/react-query": "^5.69.2",
"@trpc/client": "^11.0.0",
"@trpc/server": "^11.0.0",
"@trpc/tanstack-react-query": "^11.0.0",
"expo": "^52.0.41",
"expo-constants": "~17.0.8",
"expo-linking": "~7.0.5",
"expo-navigation-bar": "~4.0.8",
"expo-router": "~4.0.19",
"expo-status-bar": "~2.0.1",
"expo-system-ui": "~4.0.8",
"expo-web-browser": "~14.0.2",
"nativewind": "^4.1.23",
"react": "18.3.1",
"react-dom": "18.3.1",
"react-native": "0.76.7",
"react-native-gesture-handler": "~2.20.2",
"react-native-reanimated": "3.16.2",
"react-native-safe-area-context": "4.12.0",
"react-native-screens": "~4.4.0",
"react-native-web": "~0.19.13"
},
"devDependencies": {
"@babel/core": "^7.26.10",
"@types/react": "~18.3.12",
"tailwindcss": "^3.4.17",
"typescript": "~5.8.2"
},
"private": true
}

View File

@@ -0,0 +1,32 @@
const { hairlineWidth } = require("nativewind/theme");
/** @type {import('tailwindcss').Config} */
module.exports = {
darkMode: "class",
content: ["./app/**/*.{js,ts,tsx}", "./components/**/*.{js,ts,tsx}"],
presets: [require("nativewind/preset")],
theme: {
extend: {
colors: {
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
},
destructive: {
DEFAULT: "hsl(var(--destructive))",
},
},
borderWidth: {
hairline: hairlineWidth(),
},
},
},
plugins: [],
};

View File

@@ -0,0 +1,18 @@
{
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true,
"paths": {
"@/*": [
"./*"
]
}
},
"include": [
"**/*.ts",
"**/*.tsx",
".expo/types/**/*.ts",
"expo-env.d.ts",
"nativewind-env.d.ts"
]
}

View File

@@ -0,0 +1,19 @@
import type { AppRouter } from "../../server/src/routers";
import { QueryClient } from "@tanstack/react-query";
import { createTRPCClient, httpBatchLink } from "@trpc/client";
import { createTRPCOptionsProxy } from "@trpc/tanstack-react-query";
export const queryClient = new QueryClient();
const trpcClient = createTRPCClient<AppRouter>({
links: [
httpBatchLink({
url: `${process.env.EXPO_PUBLIC_SERVER_URL}/trpc`,
}),
],
});
export const trpc = createTRPCOptionsProxy<AppRouter>({
client: trpcClient,
queryClient,
});

View File

@@ -1,5 +1,5 @@
{
"name": "client",
"name": "web",
"version": "0.0.0",
"private": true,
"type": "module",

View File

@@ -0,0 +1,33 @@
import { QueryClientProvider } from "@tanstack/react-query";
import { RouterProvider, createRouter } from "@tanstack/react-router";
import ReactDOM from "react-dom/client";
import Loader from "./components/loader";
import { routeTree } from "./routeTree.gen";
import { queryClient, trpcClient } from "./utils/trpc";
const router = createRouter({
routeTree,
defaultPreload: "intent",
defaultPendingComponent: () => <Loader />,
context: { trpcClient },
Wrap: function WrapComponent({ children }) {
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
},
});
// Register things for typesafety
declare module "@tanstack/react-router" {
interface Register {
router: typeof router;
}
}
const rootElement = document.getElementById("app");
if (!rootElement) throw new Error("Root element not found");
if (!rootElement.innerHTML) {
const root = ReactDOM.createRoot(rootElement);
root.render(<RouterProvider router={router} />);
}

View File

@@ -2,19 +2,19 @@ import Header from "@/components/header";
import Loader from "@/components/loader";
import { ThemeProvider } from "@/components/theme-provider";
import { Toaster } from "@/components/ui/sonner";
import { trpcQueryUtils } from "@/main";
import type { trpcClient } from "@/utils/trpc";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import {
HeadContent,
Outlet,
createRootRouteWithContext,
useRouterState,
HeadContent,
} from "@tanstack/react-router";
import { TanStackRouterDevtools } from "@tanstack/react-router-devtools";
import "../index.css";
export interface RouterAppContext {
trpcQueryUtils: typeof trpcQueryUtils;
trpcClient: typeof trpcClient;
}
export const Route = createRootRouteWithContext<RouterAppContext>()({

View File

@@ -1,7 +1,8 @@
import { trpc } from "@/utils/trpc";
import { createFileRoute, Link } from "@tanstack/react-router";
import { ArrowRight } from "lucide-react";
import { Button } from "@/components/ui/button";
import { trpc } from "@/utils/trpc";
import { useQuery } from "@tanstack/react-query";
import { Link, createFileRoute } from "@tanstack/react-router";
import { ArrowRight } from "lucide-react";
export const Route = createFileRoute("/")({
component: HomeComponent,
@@ -24,7 +25,7 @@ const TITLE_TEXT = `
`;
function HomeComponent() {
const healthCheck = trpc.healthCheck.useQuery();
const healthCheck = useQuery(trpc.healthCheck.queryOptions());
return (
<div className="container mx-auto max-w-3xl px-4 py-2">
@@ -33,9 +34,9 @@ function HomeComponent() {
<section className="rounded-lg border p-4">
<h2 className="mb-2 font-medium">API Status</h2>
<div className="flex items-center gap-2">
<div
className={`h-2 w-2 rounded-full ${healthCheck.data ? "bg-green-500" : "bg-red-500"}`}
/>
<div
className={`h-2 w-2 rounded-full ${healthCheck.data ? "bg-green-500" : "bg-red-500"}`}
/>
<span className="text-sm text-muted-foreground">
{healthCheck.isLoading
? "Checking..."

View File

@@ -0,0 +1,33 @@
import type { AppRouter } from "../../../server/src/routers";
import { QueryCache, QueryClient } from "@tanstack/react-query";
import { createTRPCClient, httpBatchLink } from "@trpc/client";
import { createTRPCOptionsProxy } from "@trpc/tanstack-react-query";
import { toast } from "sonner";
export const queryClient = new QueryClient({
queryCache: new QueryCache({
onError: (error) => {
toast.error(error.message, {
action: {
label: "retry",
onClick: () => {
queryClient.invalidateQueries();
},
},
});
},
}),
});
export const trpcClient = createTRPCClient<AppRouter>({
links: [
httpBatchLink({
url: `${import.meta.env.VITE_SERVER_URL}/trpc`,
}),
],
});
export const trpc = createTRPCOptionsProxy<AppRouter>({
client: trpcClient,
queryClient,
});

View File

@@ -6,7 +6,8 @@
"dev": "turbo dev",
"build": "turbo build",
"check-types": "turbo check-types",
"dev:client": "turbo -F client dev",
"dev:native": "turbo -F native dev",
"dev:web": "turbo -F web dev",
"dev:server": "turbo -F server dev",
"db:push": "turbo -F server db:push",
"db:studio": "turbo -F server db:studio"

View File

@@ -1,78 +0,0 @@
import {
QueryCache,
QueryClient,
QueryClientProvider,
} from "@tanstack/react-query";
import { RouterProvider, createRouter } from "@tanstack/react-router";
import { httpBatchLink } from "@trpc/client";
import { createTRPCQueryUtils } from "@trpc/react-query";
import ReactDOM from "react-dom/client";
import { toast } from "sonner";
import Loader from "./components/loader";
import { routeTree } from "./routeTree.gen";
import { trpc } from "./utils/trpc";
const queryClient = new QueryClient({
queryCache: new QueryCache({
onError: (error) => {
toast.error(error.message, {
action: {
label: "retry",
onClick: () => {
queryClient.invalidateQueries();
},
},
});
},
}),
});
const trpcClient = trpc.createClient({
links: [
httpBatchLink({
url: `${import.meta.env.VITE_SERVER_URL}/trpc`,
fetch(url, options) {
return fetch(url, {
...options,
credentials: "include",
});
},
}),
],
});
export const trpcQueryUtils = createTRPCQueryUtils({
queryClient,
client: trpcClient,
});
const router = createRouter({
routeTree,
defaultPreload: "intent",
context: { trpcQueryUtils },
defaultPendingComponent: () => <Loader />,
Wrap: function WrapComponent({ children }) {
return (
<trpc.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
</trpc.Provider>
);
},
});
// Register things for typesafety
declare module "@tanstack/react-router" {
interface Register {
router: typeof router;
}
}
const rootElement = document.getElementById("app");
if (!rootElement) throw new Error("Root element not found");
if (!rootElement.innerHTML) {
const root = ReactDOM.createRoot(rootElement);
root.render(<RouterProvider router={router} />);
}

View File

@@ -1,5 +1,6 @@
import { authClient } from "@/lib/auth-client";
import { trpc } from "@/utils/trpc";
import { useQuery } from "@tanstack/react-query";
import { createFileRoute } from "@tanstack/react-router";
import { useEffect } from "react";
@@ -12,7 +13,7 @@ function RouteComponent() {
const navigate = Route.useNavigate();
const privateData = trpc.privateData.useQuery();
const privateData = useQuery(trpc.privateData.queryOptions());
useEffect(() => {
if (!session && !isPending) {

View File

@@ -0,0 +1,39 @@
import type { AppRouter } from "../../../server/src/routers";
import { QueryCache, QueryClient } from "@tanstack/react-query";
import { createTRPCClient, httpBatchLink } from "@trpc/client";
import { createTRPCOptionsProxy } from "@trpc/tanstack-react-query";
import { toast } from "sonner";
export const queryClient = new QueryClient({
queryCache: new QueryCache({
onError: (error) => {
toast.error(error.message, {
action: {
label: "retry",
onClick: () => {
queryClient.invalidateQueries();
},
},
});
},
}),
});
export const trpcClient = createTRPCClient<AppRouter>({
links: [
httpBatchLink({
url: `${import.meta.env.VITE_SERVER_URL}/trpc`,
fetch(url, options) {
return fetch(url, {
...options,
credentials: "include",
});
},
}),
],
});
export const trpc = createTRPCOptionsProxy<AppRouter>({
client: trpcClient,
queryClient,
});

View File

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB