add nuxt and expo with orpc

This commit is contained in:
Aman Varshney
2025-04-23 13:03:38 +05:30
parent 49c7d4f436
commit d3a80b7e63
145 changed files with 2013 additions and 874 deletions

View File

@@ -0,0 +1,5 @@
---
"create-better-t-stack": minor
---
add nuxt and expo with orpc

View File

@@ -25,8 +25,8 @@ export const DEFAULT_CONFIG: ProjectConfig = {
}; };
export const dependencyVersionMap = { export const dependencyVersionMap = {
"better-auth": "^1.2.6", "better-auth": "^1.2.7",
"@better-auth/expo": "^1.2.6", "@better-auth/expo": "^1.2.7",
"drizzle-orm": "^0.38.4", "drizzle-orm": "^0.38.4",
"drizzle-kit": "^0.30.5", "drizzle-kit": "^0.30.5",
@@ -74,12 +74,14 @@ export const dependencyVersionMap = {
ai: "^4.2.8", ai: "^4.2.8",
"@ai-sdk/google": "^1.2.3", "@ai-sdk/google": "^1.2.3",
"@ai-sdk/vue": "^1.2.8",
"@prisma/extension-accelerate": "^1.3.0", "@prisma/extension-accelerate": "^1.3.0",
"@orpc/server": "^1.0.3", "@orpc/server": "^1.1.0",
"@orpc/react-query": "^1.0.3", "@orpc/client": "^1.1.0",
"@orpc/client": "^1.0.3", "@orpc/react-query": "^1.1.0",
"@orpc/vue-query": "^1.1.0",
"@trpc/tanstack-react-query": "^11.0.0", "@trpc/tanstack-react-query": "^11.0.0",
"@trpc/server": "^11.0.0", "@trpc/server": "^11.0.0",

View File

@@ -10,8 +10,9 @@ import type { ProjectConfig } from "../types";
export async function setupAddons(config: ProjectConfig) { export async function setupAddons(config: ProjectConfig) {
const { projectName, addons, frontend } = config; const { projectName, addons, frontend } = config;
const projectDir = path.resolve(process.cwd(), projectName); const projectDir = path.resolve(process.cwd(), projectName);
const hasWebFrontend = const hasReactWebFrontend =
frontend.includes("react-router") || frontend.includes("tanstack-router"); frontend.includes("react-router") || frontend.includes("tanstack-router");
const hasNuxtFrontend = frontend.includes("nuxt");
if (addons.includes("turborepo")) { if (addons.includes("turborepo")) {
await addPackageDependency({ await addPackageDependency({
@@ -20,10 +21,10 @@ export async function setupAddons(config: ProjectConfig) {
}); });
} }
if (addons.includes("pwa") && hasWebFrontend) { if (addons.includes("pwa") && hasReactWebFrontend) {
await setupPwa(projectDir, frontend); await setupPwa(projectDir, frontend);
} }
if (addons.includes("tauri") && hasWebFrontend) { if (addons.includes("tauri") && (hasReactWebFrontend || hasNuxtFrontend)) {
await setupTauri(config); await setupTauri(config);
} }
if (addons.includes("biome")) { if (addons.includes("biome")) {
@@ -89,6 +90,11 @@ async function setupHusky(projectDir: string) {
} }
async function setupPwa(projectDir: string, frontends: ProjectFrontend[]) { async function setupPwa(projectDir: string, frontends: ProjectFrontend[]) {
const isCompatibleFrontend = frontends.some((f) =>
["react-router", "tanstack-router"].includes(f),
);
if (!isCompatibleFrontend) return;
const clientPackageDir = getWebAppDir(projectDir, frontends); const clientPackageDir = getWebAppDir(projectDir, frontends);
if (!(await fs.pathExists(clientPackageDir))) { if (!(await fs.pathExists(clientPackageDir))) {

View File

@@ -4,39 +4,84 @@ import type { ProjectConfig } from "../types";
import { addPackageDependency } from "../utils/add-package-deps"; import { addPackageDependency } from "../utils/add-package-deps";
export async function setupApi(config: ProjectConfig): Promise<void> { export async function setupApi(config: ProjectConfig): Promise<void> {
const { api, projectName } = config; const { api, projectName, frontend } = config;
const projectDir = path.resolve(process.cwd(), projectName); const projectDir = path.resolve(process.cwd(), projectName);
const webDir = path.join(projectDir, "apps/web"); const webDir = path.join(projectDir, "apps/web");
const serverDir = path.join(projectDir, "apps/server"); const serverDir = path.join(projectDir, "apps/server");
const webDirExists = await fs.pathExists(webDir); const webDirExists = await fs.pathExists(webDir);
const hasReactWeb = frontend.some((f) =>
["tanstack-router", "react-router", "tanstack-start", "next"].includes(f),
);
const hasNuxtWeb = frontend.includes("nuxt");
if (api === "orpc") { if (api === "orpc") {
if (webDirExists) {
await addPackageDependency({
dependencies: ["@orpc/react-query", "@orpc/server", "@orpc/client"],
projectDir: webDir,
});
}
await addPackageDependency({ await addPackageDependency({
dependencies: ["@orpc/server", "@orpc/client"], dependencies: ["@orpc/server", "@orpc/client"],
projectDir: serverDir, projectDir: serverDir,
}); });
} } else if (api === "trpc") {
if (api === "trpc") {
if (webDirExists) {
await addPackageDependency({
dependencies: [
"@trpc/tanstack-react-query",
"@trpc/server",
"@trpc/client",
],
projectDir: webDir,
});
}
await addPackageDependency({ await addPackageDependency({
dependencies: ["@trpc/server", "@trpc/client"], dependencies: ["@trpc/server", "@trpc/client"],
projectDir: serverDir, projectDir: serverDir,
}); });
if (config.backend === "hono") {
await addPackageDependency({
dependencies: ["@hono/trpc-server"],
projectDir: serverDir,
});
} else if (config.backend === "elysia") {
await addPackageDependency({
dependencies: ["@elysiajs/trpc"],
projectDir: serverDir,
});
}
}
if (webDirExists) {
if (hasReactWeb) {
if (api === "orpc") {
await addPackageDependency({
dependencies: ["@orpc/react-query", "@orpc/client", "@orpc/server"],
projectDir: webDir,
});
} else if (api === "trpc") {
await addPackageDependency({
dependencies: [
"@trpc/tanstack-react-query",
"@trpc/client",
"@trpc/server",
],
projectDir: webDir,
});
}
} else if (hasNuxtWeb) {
if (api === "orpc") {
await addPackageDependency({
dependencies: ["@orpc/vue-query", "@orpc/client", "@orpc/server"],
projectDir: webDir,
});
}
}
}
if (frontend.includes("native")) {
const nativeDir = path.join(projectDir, "apps/native");
if (await fs.pathExists(nativeDir)) {
if (api === "trpc") {
await addPackageDependency({
dependencies: [
"@trpc/tanstack-react-query",
"@trpc/client",
"@trpc/server",
],
projectDir: nativeDir,
});
} else if (api === "orpc") {
await addPackageDependency({
dependencies: ["@orpc/react-query", "@orpc/client", "@orpc/server"],
projectDir: nativeDir,
});
}
}
} }
} }

View File

@@ -25,11 +25,15 @@ export async function setupAuth(config: ProjectConfig): Promise<void> {
projectDir: serverDir, projectDir: serverDir,
}); });
const hasWebFrontend = const hasWebFrontend = frontend.some((f) =>
frontend.includes("react-router") || [
frontend.includes("tanstack-router") || "react-router",
frontend.includes("tanstack-start") || "tanstack-router",
frontend.includes("next"); "tanstack-start",
"next",
"nuxt",
].includes(f),
);
if (hasWebFrontend && clientDirExists) { if (hasWebFrontend && clientDirExists) {
await addPackageDependency({ await addPackageDependency({

View File

@@ -17,7 +17,6 @@ import { initializeGit, updatePackageConfigurations } from "./project-config";
import { setupRuntime } from "./runtime-setup"; import { setupRuntime } from "./runtime-setup";
import { import {
copyBaseTemplate, copyBaseTemplate,
fixGitignoreFiles,
handleExtras, handleExtras,
setupAddonsTemplate, setupAddonsTemplate,
setupAuthTemplate, setupAuthTemplate,
@@ -70,8 +69,6 @@ export async function createProject(options: ProjectConfig): Promise<string> {
await initializeGit(projectDir, options.git); await initializeGit(projectDir, options.git);
await fixGitignoreFiles(projectDir, options);
log.success("Project template successfully scaffolded!"); log.success("Project template successfully scaffolded!");
if (options.install) { if (options.install) {

View File

@@ -54,13 +54,18 @@ export async function setupEnvironmentVariables(
const hasTanStackRouter = options.frontend.includes("tanstack-router"); const hasTanStackRouter = options.frontend.includes("tanstack-router");
const hasTanStackStart = options.frontend.includes("tanstack-start"); const hasTanStackStart = options.frontend.includes("tanstack-start");
const hasNextJs = options.frontend.includes("next"); const hasNextJs = options.frontend.includes("next");
const hasNuxt = options.frontend.includes("nuxt");
const hasWebFrontend = const hasWebFrontend =
hasReactRouter || hasTanStackRouter || hasTanStackStart || hasNextJs; hasReactRouter ||
hasTanStackRouter ||
hasTanStackStart ||
hasNextJs ||
hasNuxt;
let corsOrigin = "http://localhost:3000"; let corsOrigin = "http://localhost:3001";
if (hasReactRouter) { if (hasReactRouter) {
corsOrigin = "http://localhost:5173"; corsOrigin = "http://localhost:5173";
} else if (hasTanStackRouter || hasTanStackStart || hasNextJs) { } else if (hasTanStackRouter || hasTanStackStart || hasNextJs || hasNuxt) {
corsOrigin = "http://localhost:3001"; corsOrigin = "http://localhost:3001";
} }
@@ -121,6 +126,8 @@ export async function setupEnvironmentVariables(
if (hasNextJs) { if (hasNextJs) {
envVarName = "NEXT_PUBLIC_SERVER_URL"; envVarName = "NEXT_PUBLIC_SERVER_URL";
} else if (hasNuxt) {
envVarName = "NUXT_PUBLIC_SERVER_URL";
} }
const clientVars: EnvVariable[] = [ const clientVars: EnvVariable[] = [

View File

@@ -1,10 +1,11 @@
import path from "node:path"; import path from "node:path";
import fs from "fs-extra"; import fs from "fs-extra";
import type { AvailableDependencies } from "../constants";
import type { ProjectConfig } from "../types"; import type { ProjectConfig } from "../types";
import { addPackageDependency } from "../utils/add-package-deps"; import { addPackageDependency } from "../utils/add-package-deps";
export async function setupExamples(config: ProjectConfig): Promise<void> { export async function setupExamples(config: ProjectConfig): Promise<void> {
const { projectName, examples } = config; const { projectName, examples, frontend } = config;
const projectDir = path.resolve(process.cwd(), projectName); const projectDir = path.resolve(process.cwd(), projectName);
if (examples.includes("ai")) { if (examples.includes("ai")) {
@@ -12,9 +13,15 @@ export async function setupExamples(config: ProjectConfig): Promise<void> {
const serverDir = path.join(projectDir, "apps/server"); const serverDir = path.join(projectDir, "apps/server");
const clientDirExists = await fs.pathExists(clientDir); const clientDirExists = await fs.pathExists(clientDir);
const hasNuxt = frontend.includes("nuxt");
if (clientDirExists) { if (clientDirExists) {
const dependencies: AvailableDependencies[] = ["ai"];
if (hasNuxt) {
dependencies.push("@ai-sdk/vue");
}
await addPackageDependency({ await addPackageDependency({
dependencies: ["ai"], dependencies,
projectDir: clientDir, projectDir: clientDir,
}); });
} }

View File

@@ -37,14 +37,22 @@ export function displayPostInstallInstructions(
? getNativeInstructions() ? getNativeInstructions()
: ""; : "";
const pwaInstructions = const pwaInstructions =
addons?.includes("pwa") && frontend?.includes("react-router") addons?.includes("pwa") &&
(frontend?.includes("react-router") ||
frontend?.includes("tanstack-router")) // Exclude Nuxt from PWA instructions
? getPwaInstructions() ? getPwaInstructions()
: ""; : "";
const starlightInstructions = addons?.includes("starlight") const starlightInstructions = addons?.includes("starlight")
? getStarlightInstructions(runCmd) ? getStarlightInstructions(runCmd)
: ""; : "";
const hasWeb = frontend?.some((f) => const hasWeb = frontend?.some((f) =>
["tanstack-router", "react-router", "next", "tanstack-start"].includes(f), [
"tanstack-router",
"react-router",
"next",
"tanstack-start",
"nuxt", // Include Nuxt here
].includes(f),
); );
const hasNative = frontend?.includes("native"); const hasNative = frontend?.includes("native");
const bunWebNativeWarning = const bunWebNativeWarning =
@@ -57,12 +65,13 @@ export function displayPostInstallInstructions(
const hasTanstackRouter = frontend?.includes("tanstack-router"); const hasTanstackRouter = frontend?.includes("tanstack-router");
const hasTanstackStart = frontend?.includes("tanstack-start"); const hasTanstackStart = frontend?.includes("tanstack-start");
const hasReactRouter = frontend?.includes("react-router"); const hasReactRouter = frontend?.includes("react-router");
const hasNuxt = frontend?.includes("nuxt"); // Add Nuxt check
const hasWebFrontend = const hasWebFrontend =
hasTanstackRouter || hasReactRouter || hasTanstackStart; hasTanstackRouter || hasReactRouter || hasTanstackStart || hasNuxt; // Include Nuxt
const hasNativeFrontend = frontend?.includes("native"); const hasNativeFrontend = frontend?.includes("native");
const hasFrontend = hasWebFrontend || hasNativeFrontend; const hasFrontend = hasWebFrontend || hasNativeFrontend;
const webPort = hasReactRouter ? "5173" : "3001"; const webPort = hasReactRouter ? "5173" : "3001"; // Nuxt uses 3001, same as others
const tazeCommand = getPackageExecutionCommand(packageManager, "taze -r"); const tazeCommand = getPackageExecutionCommand(packageManager, "taze -r");
consola.box( consola.box(
@@ -93,7 +102,9 @@ ${
lintingInstructions ? `\n${lintingInstructions.trim()}` : "" lintingInstructions ? `\n${lintingInstructions.trim()}` : ""
}${pwaInstructions ? `\n${pwaInstructions.trim()}` : ""}${ }${pwaInstructions ? `\n${pwaInstructions.trim()}` : ""}${
starlightInstructions ? `\n${starlightInstructions.trim()}` : "" starlightInstructions ? `\n${starlightInstructions.trim()}` : ""
}${noOrmWarning ? `\n${noOrmWarning.trim()}` : ""}${bunWebNativeWarning ? `\n${bunWebNativeWarning.trim()}` : ""} }${noOrmWarning ? `\n${noOrmWarning.trim()}` : ""}${
bunWebNativeWarning ? `\n${bunWebNativeWarning.trim()}` : ""
}
${pc.bold("Update all dependencies:\n")}${pc.cyan(tazeCommand)} ${pc.bold("Update all dependencies:\n")}${pc.cyan(tazeCommand)}
@@ -177,9 +188,13 @@ function getStarlightInstructions(runCmd?: string): string {
} }
function getNoOrmWarning(): string { function getNoOrmWarning(): string {
return `\n${pc.yellow("WARNING:")} Database selected without an ORM. Features requiring database access (e.g., examples, auth) need manual setup.\n`; return `\n${pc.yellow(
"WARNING:",
)} Database selected without an ORM. Features requiring database access (e.g., examples, auth) need manual setup.\n`;
} }
function getBunWebNativeWarning(): string { function getBunWebNativeWarning(): string {
return `\n${pc.yellow("WARNING:")} 'bun' might cause issues with web + native apps in a monorepo. Use 'pnpm' if problems arise.\n`; return `\n${pc.yellow(
"WARNING:",
)} 'bun' might cause issues with web + native apps in a monorepo. Use 'pnpm' if problems arise.\n`;
} }

View File

@@ -3,6 +3,7 @@ import { log } from "@clack/prompts";
import { $, execa } from "execa"; import { $, execa } from "execa";
import fs from "fs-extra"; import fs from "fs-extra";
import pc from "picocolors"; import pc from "picocolors";
import { dependencyVersionMap } from "../constants";
import type { ProjectConfig } from "../types"; import type { ProjectConfig } from "../types";
export async function updatePackageConfigurations( export async function updatePackageConfigurations(

View File

@@ -42,15 +42,19 @@ export async function setupTauri(config: ProjectConfig): Promise<void> {
} }
const hasReactRouter = frontend.includes("react-router"); const hasReactRouter = frontend.includes("react-router");
const hasNuxt = frontend.includes("nuxt");
const devUrl = hasReactRouter const devUrl = hasReactRouter
? "http://localhost:5173" ? "http://localhost:5173"
: "http://localhost:3001"; : "http://localhost:3001";
const frontendDist = hasNuxt ? "../.output/public" : "../dist";
const tauriArgs = [ const tauriArgs = [
"init", "init",
`--app-name=${path.basename(projectDir)}`, `--app-name=${path.basename(projectDir)}`,
`--window-title=${path.basename(projectDir)}`, `--window-title=${path.basename(projectDir)}`,
"--frontend-dist=../dist", `--frontend-dist=${frontendDist}`,
`--dev-url=${devUrl}`, `--dev-url=${devUrl}`,
`--before-dev-command=\"${packageManager} run dev\"`, `--before-dev-command=\"${packageManager} run dev\"`,
`--before-build-command=\"${packageManager} run build\"`, `--before-build-command=\"${packageManager} run build\"`,

View File

@@ -28,17 +28,21 @@ async function processAndCopyFiles(
if (relativeSrcPath.endsWith(".hbs")) { if (relativeSrcPath.endsWith(".hbs")) {
relativeDestPath = relativeSrcPath.slice(0, -4); relativeDestPath = relativeSrcPath.slice(0, -4);
} }
if (path.basename(relativeSrcPath) === "_gitignore") {
relativeDestPath = path.join(path.dirname(relativeSrcPath), ".gitignore");
}
const destPath = path.join(destDir, relativeDestPath); const destPath = path.join(destDir, relativeDestPath);
await fs.ensureDir(path.dirname(destPath)); await fs.ensureDir(path.dirname(destPath));
if (!overwrite && (await fs.pathExists(destPath))) {
continue;
}
if (srcPath.endsWith(".hbs")) { if (srcPath.endsWith(".hbs")) {
await processTemplate(srcPath, destPath, context); await processTemplate(srcPath, destPath, context);
} else { } else {
if (!overwrite && (await fs.pathExists(destPath))) {
continue;
}
await fs.copy(srcPath, destPath, { overwrite: true }); await fs.copy(srcPath, destPath, { overwrite: true });
} }
} }
@@ -49,62 +53,69 @@ export async function copyBaseTemplate(
context: ProjectConfig, context: ProjectConfig,
): Promise<void> { ): Promise<void> {
const templateDir = path.join(PKG_ROOT, "templates/base"); const templateDir = path.join(PKG_ROOT, "templates/base");
await processAndCopyFiles( await processAndCopyFiles(["**/*"], templateDir, projectDir, context);
["package.json", "_gitignore"],
templateDir,
projectDir,
context,
);
} }
export async function setupFrontendTemplates( export async function setupFrontendTemplates(
projectDir: string, projectDir: string,
context: ProjectConfig, context: ProjectConfig,
): Promise<void> { ): Promise<void> {
const webFrontends = context.frontend.filter( const hasReactWeb = context.frontend.some((f) =>
(f) => ["tanstack-router", "react-router", "tanstack-start", "next"].includes(f),
f === "tanstack-router" ||
f === "react-router" ||
f === "tanstack-start" ||
f === "next",
); );
const hasNuxtWeb = context.frontend.includes("nuxt");
const hasNative = context.frontend.includes("native"); const hasNative = context.frontend.includes("native");
if (webFrontends.length > 0) { if (hasReactWeb || hasNuxtWeb) {
const webAppDir = path.join(projectDir, "apps/web"); const webAppDir = path.join(projectDir, "apps/web");
await fs.ensureDir(webAppDir); await fs.ensureDir(webAppDir);
const webBaseDir = path.join(PKG_ROOT, "templates/frontend/web-base"); if (hasReactWeb) {
if (await fs.pathExists(webBaseDir)) { const webBaseDir = path.join(
await processAndCopyFiles("**/*", webBaseDir, webAppDir, context);
}
for (const framework of webFrontends) {
const frameworkSrcDir = path.join(
PKG_ROOT, PKG_ROOT,
`templates/frontend/${framework}`, "templates/frontend/react/web-base",
); );
if (await fs.pathExists(frameworkSrcDir)) { if (await fs.pathExists(webBaseDir)) {
await processAndCopyFiles("**/*", frameworkSrcDir, webAppDir, context); await processAndCopyFiles("**/*", webBaseDir, webAppDir, context);
}
const reactFramework = context.frontend.find((f) =>
["tanstack-router", "react-router", "tanstack-start", "next"].includes(
f,
),
);
if (reactFramework) {
const frameworkSrcDir = path.join(
PKG_ROOT,
`templates/frontend/react/${reactFramework}`,
);
if (await fs.pathExists(frameworkSrcDir)) {
await processAndCopyFiles(
"**/*",
frameworkSrcDir,
webAppDir,
context,
);
}
const apiWebBaseDir = path.join(
PKG_ROOT,
`templates/api/${context.api}/web/react/base`,
);
if (await fs.pathExists(apiWebBaseDir)) {
await processAndCopyFiles("**/*", apiWebBaseDir, webAppDir, context);
}
}
} else if (hasNuxtWeb) {
const nuxtBaseDir = path.join(PKG_ROOT, "templates/frontend/nuxt");
if (await fs.pathExists(nuxtBaseDir)) {
await processAndCopyFiles("**/*", nuxtBaseDir, webAppDir, context);
}
const apiWebNuxtDir = path.join(
PKG_ROOT,
`templates/api/${context.api}/web/nuxt`,
);
if (await fs.pathExists(apiWebNuxtDir)) {
await processAndCopyFiles("**/*", apiWebNuxtDir, webAppDir, context);
} }
}
const webFramework = webFrontends[0];
const apiWebBaseDir = path.join(
PKG_ROOT,
`templates/api/${context.api}/web/base`,
);
if (await fs.pathExists(apiWebBaseDir)) {
await processAndCopyFiles("**/*", apiWebBaseDir, webAppDir, context);
}
const apiWebFrameworkDir = path.join(
PKG_ROOT,
`templates/api/${context.api}/web/${webFramework}`,
);
if (await fs.pathExists(apiWebFrameworkDir)) {
await processAndCopyFiles("**/*", apiWebFrameworkDir, webAppDir, context);
} }
} }
@@ -117,14 +128,32 @@ export async function setupFrontendTemplates(
await processAndCopyFiles("**/*", nativeBaseDir, nativeAppDir, context); await processAndCopyFiles("**/*", nativeBaseDir, nativeAppDir, context);
} }
const apiNativeSrcDir = path.join( if (context.api === "trpc") {
PKG_ROOT, const apiNativeSrcDir = path.join(
`templates/api/${context.api}/native`, PKG_ROOT,
); `templates/api/${context.api}/native`,
);
if (await fs.pathExists(apiNativeSrcDir)) { if (await fs.pathExists(apiNativeSrcDir)) {
await processAndCopyFiles("**/*", apiNativeSrcDir, nativeAppDir, context); await processAndCopyFiles(
} else { "**/*",
apiNativeSrcDir,
nativeAppDir,
context,
);
}
} else if (context.api === "orpc") {
const apiNativeSrcDir = path.join(
PKG_ROOT,
`templates/api/${context.api}/native`,
);
if (await fs.pathExists(apiNativeSrcDir)) {
await processAndCopyFiles(
"**/*",
apiNativeSrcDir,
nativeAppDir,
context,
);
}
} }
} }
} }
@@ -222,13 +251,10 @@ export async function setupAuthTemplate(
const webAppDirExists = await fs.pathExists(webAppDir); const webAppDirExists = await fs.pathExists(webAppDir);
const nativeAppDirExists = await fs.pathExists(nativeAppDir); const nativeAppDirExists = await fs.pathExists(nativeAppDir);
const webFrontends = context.frontend.filter( const hasReactWeb = context.frontend.some((f) =>
(f) => ["tanstack-router", "react-router", "tanstack-start", "next"].includes(f),
f === "tanstack-router" ||
f === "react-router" ||
f === "tanstack-start" ||
f === "next",
); );
const hasNuxtWeb = context.frontend.includes("nuxt");
const hasNative = context.frontend.includes("native"); const hasNative = context.frontend.includes("native");
if (serverAppDirExists) { if (serverAppDirExists) {
@@ -240,12 +266,6 @@ export async function setupAuthTemplate(
serverAppDir, serverAppDir,
context, context,
); );
} else {
consola.warn(
pc.yellow(
`Warning: Base auth server template not found at ${authServerBaseSrc}`,
),
);
} }
if (context.backend === "next") { if (context.backend === "next") {
@@ -260,12 +280,6 @@ export async function setupAuthTemplate(
serverAppDir, serverAppDir,
context, context,
); );
} else {
consola.warn(
pc.yellow(
`Warning: Next auth server template not found at ${authServerNextSrc}`,
),
);
} }
} }
@@ -294,44 +308,40 @@ export async function setupAuthTemplate(
); );
} }
} }
} else {
consola.warn(
pc.yellow(
"Warning: apps/server directory does not exist, skipping server-side auth template setup.",
),
);
} }
if (webFrontends.length > 0 && webAppDirExists) { if ((hasReactWeb || hasNuxtWeb) && webAppDirExists) {
const authWebBaseSrc = path.join(PKG_ROOT, "templates/auth/web/base"); if (hasReactWeb) {
if (await fs.pathExists(authWebBaseSrc)) { const authWebBaseSrc = path.join(
await processAndCopyFiles("**/*", authWebBaseSrc, webAppDir, context); PKG_ROOT,
} else { "templates/auth/web/react/base",
consola.warn( );
pc.yellow( if (await fs.pathExists(authWebBaseSrc)) {
`Warning: Base auth web template not found at ${authWebBaseSrc}`, await processAndCopyFiles("**/*", authWebBaseSrc, webAppDir, context);
}
const reactFramework = context.frontend.find((f) =>
["tanstack-router", "react-router", "tanstack-start", "next"].includes(
f,
), ),
); );
} if (reactFramework) {
const authWebFrameworkSrc = path.join(
for (const framework of webFrontends) { PKG_ROOT,
const authWebFrameworkSrc = path.join( `templates/auth/web/react/${reactFramework}`,
PKG_ROOT,
`templates/auth/web/${framework}`,
);
if (await fs.pathExists(authWebFrameworkSrc)) {
await processAndCopyFiles(
"**/*",
authWebFrameworkSrc,
webAppDir,
context,
);
} else {
consola.warn(
pc.yellow(
`Warning: Auth web template for ${framework} not found at ${authWebFrameworkSrc}`,
),
); );
if (await fs.pathExists(authWebFrameworkSrc)) {
await processAndCopyFiles(
"**/*",
authWebFrameworkSrc,
webAppDir,
context,
);
}
}
} else if (hasNuxtWeb) {
const authWebNuxtSrc = path.join(PKG_ROOT, "templates/auth/web/nuxt");
if (await fs.pathExists(authWebNuxtSrc)) {
await processAndCopyFiles("**/*", authWebNuxtSrc, webAppDir, context);
} }
} }
} }
@@ -354,50 +364,25 @@ export async function setupAddonsTemplate(
projectDir: string, projectDir: string,
context: ProjectConfig, context: ProjectConfig,
): Promise<void> { ): Promise<void> {
if (context.addons.includes("turborepo")) { if (!context.addons || context.addons.length === 0) return;
const turboSrcDir = path.join(PKG_ROOT, "templates/addons/turborepo");
if (await fs.pathExists(turboSrcDir)) {
await processAndCopyFiles("**/*", turboSrcDir, projectDir, context);
} else {
consola.warn(pc.yellow("Warning: Turborepo addon template not found."));
}
}
if (context.addons.includes("husky")) { for (const addon of context.addons) {
const huskySrcDir = path.join(PKG_ROOT, "templates/addons/husky"); if (addon === "none") continue;
if (await fs.pathExists(huskySrcDir)) {
await processAndCopyFiles("**/*", huskySrcDir, projectDir, context);
} else {
consola.warn(pc.yellow("Warning: Husky addon template not found."));
}
}
if (context.addons.includes("biome")) { let addonSrcDir = path.join(PKG_ROOT, `templates/addons/${addon}`);
const biomeSrcDir = path.join(PKG_ROOT, "templates/addons/biome"); let addonDestDir = projectDir;
if (await fs.pathExists(biomeSrcDir)) {
await processAndCopyFiles("**/*", biomeSrcDir, projectDir, context);
} else {
consola.warn(pc.yellow("Warning: Biome addon template not found."));
}
}
if (context.addons.includes("pwa")) { if (addon === "pwa") {
const pwaSrcDir = path.join(PKG_ROOT, "templates/addons/pwa/apps/web"); addonSrcDir = path.join(PKG_ROOT, "templates/addons/pwa/apps/web");
const webAppDir = path.join(projectDir, "apps/web"); addonDestDir = path.join(projectDir, "apps/web");
const webAppDirExists = await fs.pathExists(webAppDir); if (!(await fs.pathExists(addonDestDir))) {
continue;
if (await fs.pathExists(pwaSrcDir)) {
if (webAppDirExists) {
await processAndCopyFiles("**/*", pwaSrcDir, webAppDir, context);
} else {
consola.warn(
pc.yellow(
"Warning: apps/web directory not found, cannot setup PWA addon template.",
),
);
} }
}
if (await fs.pathExists(addonSrcDir)) {
await processAndCopyFiles("**/*", addonSrcDir, addonDestDir, context);
} else { } else {
consola.warn(pc.yellow("Warning: PWA addon template not found."));
} }
} }
} }
@@ -414,7 +399,14 @@ export async function setupExamplesTemplate(
const serverAppDirExists = await fs.pathExists(serverAppDir); const serverAppDirExists = await fs.pathExists(serverAppDir);
const webAppDirExists = await fs.pathExists(webAppDir); const webAppDirExists = await fs.pathExists(webAppDir);
const hasReactWeb = context.frontend.some((f) =>
["tanstack-router", "react-router", "tanstack-start", "next"].includes(f),
);
const hasNuxtWeb = context.frontend.includes("nuxt");
for (const example of context.examples) { for (const example of context.examples) {
if (example === "none") continue;
const exampleBaseDir = path.join(PKG_ROOT, `templates/examples/${example}`); const exampleBaseDir = path.join(PKG_ROOT, `templates/examples/${example}`);
if (serverAppDirExists) { if (serverAppDirExists) {
@@ -456,10 +448,10 @@ export async function setupExamplesTemplate(
} }
} }
if (webAppDirExists) { if (hasReactWeb && webAppDirExists) {
const exampleWebSrc = path.join(exampleBaseDir, "web"); const exampleWebSrc = path.join(exampleBaseDir, "web/react");
if (await fs.pathExists(exampleWebSrc)) { if (await fs.pathExists(exampleWebSrc)) {
const webFrameworks = context.frontend.filter((f) => const reactFramework = context.frontend.find((f) =>
[ [
"next", "next",
"react-router", "react-router",
@@ -467,8 +459,11 @@ export async function setupExamplesTemplate(
"tanstack-start", "tanstack-start",
].includes(f), ].includes(f),
); );
for (const framework of webFrameworks) { if (reactFramework) {
const exampleWebFrameworkSrc = path.join(exampleWebSrc, framework); const exampleWebFrameworkSrc = path.join(
exampleWebSrc,
reactFramework,
);
if (await fs.pathExists(exampleWebFrameworkSrc)) { if (await fs.pathExists(exampleWebFrameworkSrc)) {
await processAndCopyFiles( await processAndCopyFiles(
"**/*", "**/*",
@@ -480,36 +475,27 @@ export async function setupExamplesTemplate(
} }
} }
} }
} } else if (hasNuxtWeb && webAppDirExists) {
} // Only copy Nuxt examples if the API is oRPC (as tRPC is not supported)
} if (context.api === "orpc") {
const exampleWebNuxtSrc = path.join(exampleBaseDir, "web/nuxt");
export async function fixGitignoreFiles( if (await fs.pathExists(exampleWebNuxtSrc)) {
projectDir: string, await processAndCopyFiles(
context: ProjectConfig, "**/*",
): Promise<void> { exampleWebNuxtSrc,
const gitignoreFiles = await globby(["**/.gitignore.hbs", "**/_gitignore"], { webAppDir,
cwd: projectDir, context,
dot: true, false,
onlyFiles: true, );
absolute: true, } else {
ignore: ["**/node_modules/**", "**/.git/**"], consola.info(
}); pc.gray(
`Skipping Nuxt web template for example '${example}' (template not found).`,
for (const currentPath of gitignoreFiles) { ),
const dir = path.dirname(currentPath); );
const filename = path.basename(currentPath); }
const destPath = path.join(dir, ".gitignore");
try {
if (filename === ".gitignore.hbs") {
await processTemplate(currentPath, destPath, context);
await fs.remove(currentPath);
} else if (filename === "_gitignore") {
await fs.move(currentPath, destPath, { overwrite: true });
} }
} catch (error) { // If API is tRPC, skip Nuxt examples silently as CLI validation prevents this combo.
consola.error(`Error processing gitignore file ${currentPath}:`, error);
} }
} }
} }
@@ -518,24 +504,24 @@ export async function handleExtras(
projectDir: string, projectDir: string,
context: ProjectConfig, context: ProjectConfig,
): Promise<void> { ): Promise<void> {
const extrasDir = path.join(PKG_ROOT, "templates/extras");
if (context.packageManager === "pnpm") { if (context.packageManager === "pnpm") {
const pnpmWorkspaceSrc = path.join( const pnpmWorkspaceSrc = path.join(extrasDir, "pnpm-workspace.yaml");
PKG_ROOT,
"templates/extras/pnpm-workspace.yaml",
);
const pnpmWorkspaceDest = path.join(projectDir, "pnpm-workspace.yaml"); const pnpmWorkspaceDest = path.join(projectDir, "pnpm-workspace.yaml");
if (await fs.pathExists(pnpmWorkspaceSrc)) { if (await fs.pathExists(pnpmWorkspaceSrc)) {
await fs.copy(pnpmWorkspaceSrc, pnpmWorkspaceDest); await fs.copy(pnpmWorkspaceSrc, pnpmWorkspaceDest);
} else {
} }
} }
if (context.frontend.includes("native")) { if (
const npmrcSrc = path.join(PKG_ROOT, "templates/extras/_npmrc"); context.packageManager === "pnpm" &&
(context.frontend.includes("native") || context.frontend.includes("nuxt"))
) {
const npmrcTemplateSrc = path.join(extrasDir, "_npmrc.hbs");
const npmrcDest = path.join(projectDir, ".npmrc"); const npmrcDest = path.join(projectDir, ".npmrc");
if (await fs.pathExists(npmrcSrc)) { if (await fs.pathExists(npmrcTemplateSrc)) {
await fs.copy(npmrcSrc, npmrcDest); await processTemplate(npmrcTemplateSrc, npmrcDest, context);
} else {
} }
} }
} }

View File

@@ -75,6 +75,7 @@ async function main() {
"react-router", "react-router",
"tanstack-start", "tanstack-start",
"next", "next",
"nuxt",
"native", "native",
"none", "none",
], ],
@@ -268,11 +269,12 @@ function processAndValidateFlags(
f === "tanstack-router" || f === "tanstack-router" ||
f === "react-router" || f === "react-router" ||
f === "tanstack-start" || f === "tanstack-start" ||
f === "next", f === "next" ||
f === "nuxt",
); );
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", "Cannot select multiple web frameworks. Choose only one of: tanstack-router, tanstack-start, react-router, next, nuxt",
); );
process.exit(1); process.exit(1);
} }
@@ -445,18 +447,20 @@ function processAndValidateFlags(
} }
const includesNative = effectiveFrontend?.includes("native"); const includesNative = effectiveFrontend?.includes("native");
if (includesNative && effectiveApi === "orpc") { const includesNuxt = effectiveFrontend?.includes("nuxt");
if (includesNuxt && effectiveApi === "trpc") {
consola.fatal( consola.fatal(
`oRPC API is not supported with 'native' frontend. Please use --api trpc or remove 'native' from --frontend.`, `tRPC API is not supported with 'nuxt' frontend. Please use --api orpc or remove 'nuxt' from --frontend.`,
); );
process.exit(1); process.exit(1);
} }
if ( if (
includesNative && includesNuxt &&
effectiveApi !== "trpc" && effectiveApi !== "orpc" &&
(!options.api || (options.yes && options.api !== "orpc")) (!options.api || (options.yes && options.api !== "trpc"))
) { ) {
config.api = "trpc"; config.api = "orpc";
} }
if (config.addons && config.addons.length > 0) { if (config.addons && config.addons.length > 0) {
@@ -465,18 +469,35 @@ function processAndValidateFlags(
webSpecificAddons.includes(addon), webSpecificAddons.includes(addon),
); );
const hasCompatibleWebFrontend = effectiveFrontend?.some( const hasCompatibleWebFrontend = effectiveFrontend?.some(
(f) => f === "tanstack-router" || f === "react-router", (f) =>
f === "tanstack-router" ||
f === "react-router" ||
(f === "nuxt" &&
config.addons?.includes("tauri") &&
!config.addons?.includes("pwa")), // Nuxt compatible with Tauri, not PWA
); );
if (hasWebSpecificAddons && !hasCompatibleWebFrontend) { if (hasWebSpecificAddons && !hasCompatibleWebFrontend) {
let incompatibleAddon = "";
if (config.addons.includes("pwa") && includesNuxt) {
incompatibleAddon = "PWA addon is not compatible with Nuxt.";
} else if (
config.addons.includes("pwa") ||
config.addons.includes("tauri")
) {
incompatibleAddon =
"PWA and Tauri addons require tanstack-router, react-router, or Nuxt (Tauri only).";
}
consola.fatal( consola.fatal(
"PWA and Tauri addons require tanstack-router or react-router. Cannot use these addons with your frontend selection.", `${incompatibleAddon} Cannot use these addons with your frontend selection.`,
); );
process.exit(1); process.exit(1);
} }
if (config.addons.includes("husky") && !config.addons.includes("biome")) { if (config.addons.includes("husky") && !config.addons.includes("biome")) {
config.addons.push("biome"); consola.warn(
"Husky addon is recommended to be used with Biome for lint-staged configuration.",
);
} }
config.addons = [...new Set(config.addons)]; config.addons = [...new Set(config.addons)];
} }
@@ -484,17 +505,24 @@ function processAndValidateFlags(
if (config.examples && config.examples.length > 0) { if (config.examples && config.examples.length > 0) {
if (config.examples.includes("ai") && effectiveBackend === "elysia") { if (config.examples.includes("ai") && effectiveBackend === "elysia") {
consola.fatal( consola.fatal(
"AI example is not compatible with Elysia backend. Cannot use --examples ai with --backend elysia", "The 'ai' example is not compatible with the Elysia backend.",
); );
process.exit(1); process.exit(1);
} }
const hasWebFrontendForExamples = effectiveFrontend?.some((f) => const hasWebFrontendForExamples = effectiveFrontend?.some((f) =>
["tanstack-router", "react-router", "tanstack-start", "next"].includes(f), [
"tanstack-router",
"react-router",
"tanstack-start",
"next",
"nuxt",
].includes(f),
); );
if (!hasWebFrontendForExamples) {
if (config.examples.length > 0 && !hasWebFrontendForExamples) {
consola.fatal( consola.fatal(
"Examples require a web frontend (tanstack-router, react-router, tanstack-start, or next). Cannot use --examples with your frontend selection.", "Examples require a web frontend (tanstack-router, react-router, tanstack-start, next, or nuxt).",
); );
process.exit(1); process.exit(1);
} }
@@ -507,12 +535,8 @@ main().catch((err) => {
consola.error("Aborting installation due to unexpected error..."); consola.error("Aborting installation due to unexpected error...");
if (err instanceof Error) { if (err instanceof Error) {
consola.error(err.message); consola.error(err.message);
console.error(err.stack);
} else { } else {
consola.error(
"An unknown error has occurred. Please open an issue on GitHub with the below:",
);
console.error(err); console.error(err);
} }
process.exit(1); process.exit(1); // Ensure exit on error
}); });

View File

@@ -9,7 +9,7 @@ export async function getApiChoice(
): Promise<ProjectApi> { ): Promise<ProjectApi> {
if (Api) return Api; if (Api) return Api;
const includesNative = frontend?.includes("native"); const includesNuxt = frontend?.includes("nuxt");
let apiOptions = [ let apiOptions = [
{ {
@@ -24,12 +24,12 @@ export async function getApiChoice(
}, },
]; ];
if (includesNative) { if (includesNuxt) {
apiOptions = [ apiOptions = [
{ {
value: "trpc" as const, value: "orpc" as const,
label: "tRPC", label: "oRPC",
hint: "End-to-end typesafe APIs made easy (Required for Native frontend)", hint: "End-to-end type-safe APIs (Required for Nuxt frontend)",
}, },
]; ];
} }
@@ -37,7 +37,7 @@ export async function getApiChoice(
const apiType = await select<ProjectApi>({ const apiType = await select<ProjectApi>({
message: "Select API type", message: "Select API type",
options: apiOptions, options: apiOptions,
initialValue: includesNative ? "trpc" : DEFAULT_CONFIG.api, initialValue: includesNuxt ? "orpc" : DEFAULT_CONFIG.api,
}); });
if (isCancel(apiType)) { if (isCancel(apiType)) {
@@ -45,8 +45,8 @@ export async function getApiChoice(
process.exit(0); process.exit(0);
} }
if (includesNative && apiType !== "trpc") { if (includesNuxt && apiType !== "orpc") {
return "trpc"; return "orpc";
} }
return apiType; return apiType;

View File

@@ -2,7 +2,6 @@ import { cancel, isCancel, multiselect } from "@clack/prompts";
import pc from "picocolors"; import pc from "picocolors";
import { DEFAULT_CONFIG } from "../constants"; import { DEFAULT_CONFIG } from "../constants";
import type { import type {
ProjectApi,
ProjectBackend, ProjectBackend,
ProjectDatabase, ProjectDatabase,
ProjectExamples, ProjectExamples,
@@ -22,46 +21,36 @@ export async function getExamplesChoice(
const hasWebFrontend = const hasWebFrontend =
frontends?.includes("react-router") || frontends?.includes("react-router") ||
frontends?.includes("tanstack-router") || frontends?.includes("tanstack-router") ||
frontends?.includes("tanstack-start"); frontends?.includes("tanstack-start") ||
frontends?.includes("next") || // Added next
frontends?.includes("nuxt"); // Added nuxt
if (!hasWebFrontend) return []; if (!hasWebFrontend) return [];
let response: ProjectExamples[] | symbol = []; let response: ProjectExamples[] | symbol = [];
const options: { value: ProjectExamples; label: string; hint: string }[] = [
{
value: "todo" as const,
label: "Todo App",
hint: "A simple CRUD example app",
},
];
if (backend === "elysia") { // AI example is available for hono, express, next backends, and Nuxt (if backend is not elysia)
response = await multiselect<ProjectExamples>({ if (backend !== "elysia") {
message: "Include examples", options.push({
options: [ value: "ai" as const,
{ label: "AI Chat",
value: "todo", hint: "A simple AI chat interface using AI SDK",
label: "Todo App",
hint: "A simple CRUD example app",
},
],
required: false,
initialValues: DEFAULT_CONFIG.examples,
}); });
} }
if (backend === "hono" || backend === "express") { response = await multiselect<ProjectExamples>({
response = await multiselect<ProjectExamples>({ message: "Include examples",
message: "Include examples", options: options,
options: [ required: false,
{ initialValues: DEFAULT_CONFIG.examples,
value: "todo", });
label: "Todo App",
hint: "A simple CRUD example app",
},
{
value: "ai",
label: "AI Chat",
hint: "A simple AI chat interface using AI SDK",
},
],
required: false,
initialValues: DEFAULT_CONFIG.examples,
});
}
if (isCancel(response)) { if (isCancel(response)) {
cancel(pc.red("Operation cancelled")); cancel(pc.red("Operation cancelled"));

View File

@@ -14,7 +14,7 @@ export async function getFrontendChoice(
{ {
value: "web", value: "web",
label: "Web", label: "Web",
hint: "React Web Application", hint: "React or Vue Web Application",
}, },
{ {
value: "native", value: "native",
@@ -28,7 +28,8 @@ export async function getFrontendChoice(
f === "tanstack-router" || f === "tanstack-router" ||
f === "react-router" || f === "react-router" ||
f === "tanstack-start" || f === "tanstack-start" ||
f === "next", f === "next" ||
f === "nuxt",
) )
? ["web"] ? ["web"]
: [], : [],
@@ -60,6 +61,11 @@ export async function getFrontendChoice(
label: "Next.js", label: "Next.js",
hint: "The React Framework for the Web", hint: "The React Framework for the Web",
}, },
{
value: "nuxt",
label: "Nuxt",
hint: "The Progressive Web Framework for Vue.js",
},
{ {
value: "tanstack-start", value: "tanstack-start",
label: "TanStack Start (beta)", label: "TanStack Start (beta)",

View File

@@ -22,6 +22,7 @@ export type ProjectFrontend =
| "tanstack-router" | "tanstack-router"
| "tanstack-start" | "tanstack-start"
| "next" | "next"
| "nuxt"
| "native" | "native"
| "none"; | "none";
export type ProjectDBSetup = export type ProjectDBSetup =

View File

@@ -0,0 +1,49 @@
import { createORPCClient } from "@orpc/client";
import { RPCLink } from "@orpc/client/fetch";
import { createORPCReactQueryUtils } from "@orpc/react-query";
import type { RouterUtils } from "@orpc/react-query";
import type { RouterClient } from "@orpc/server";
import { QueryCache, QueryClient } from "@tanstack/react-query";
import { createContext, useContext } from "react";
import type { appRouter } from "../../server/src/routers";
{{#if auth}}
import { authClient } from "@/lib/auth-client";
{{/if}}
type ORPCReactUtils = RouterUtils<RouterClient<typeof appRouter>>;
export const queryClient = new QueryClient({
queryCache: new QueryCache({
onError: (error) => {
console.log(error)
},
}),
});
export const link = new RPCLink({
url: `${process.env.EXPO_PUBLIC_SERVER_URL}/rpc`,
{{#if auth}}
headers() {
const headers = new Map<string, string>();
const cookies = authClient.getCookie();
if (cookies) {
headers.set("Cookie", cookies);
}
return Object.fromEntries(headers);
},
{{/if}}
});
export const client: RouterClient<typeof appRouter> = createORPCClient(link);
export const orpc = createORPCReactQueryUtils(client);
export const ORPCContext = createContext<ORPCReactUtils | undefined>(undefined);
export function useORPC(): ORPCReactUtils {
const orpc = useContext(ORPCContext);
if (!orpc) {
throw new Error("ORPCContext is not set up properly");
}
return orpc;
}

View File

@@ -0,0 +1,35 @@
import { defineNuxtPlugin, useRuntimeConfig } from '#app'
import type { RouterClient } from '@orpc/server'
import type { appRouter } from "../../../server/src/routers/index";
import { createORPCClient } from '@orpc/client'
import { RPCLink } from '@orpc/client/fetch'
import { createORPCVueQueryUtils } from '@orpc/vue-query'
export default defineNuxtPlugin(() => {
const config = useRuntimeConfig()
const serverUrl = config.public.serverURL
const rpcUrl = `${serverUrl}/rpc`;
const rpcLink = new RPCLink({
url: rpcUrl,
{{#if auth}}
fetch(url, options) {
return fetch(url, {
...options,
credentials: "include",
});
},
{{/if}}
})
const client: RouterClient<typeof appRouter> = createORPCClient(rpcLink)
const orpcUtils = createORPCVueQueryUtils(client)
return {
provide: {
orpc: orpcUtils
}
}
})

View File

@@ -1,4 +1,6 @@
{{#if auth}}
import { authClient } from "@/lib/auth-client"; import { authClient } from "@/lib/auth-client";
{{/if}}
import { QueryClient } from "@tanstack/react-query"; import { QueryClient } from "@tanstack/react-query";
import { createTRPCClient, httpBatchLink } from "@trpc/client"; import { createTRPCClient, httpBatchLink } from "@trpc/client";
import { createTRPCOptionsProxy } from "@trpc/tanstack-react-query"; import { createTRPCOptionsProxy } from "@trpc/tanstack-react-query";
@@ -10,6 +12,7 @@ const trpcClient = createTRPCClient<AppRouter>({
links: [ links: [
httpBatchLink({ httpBatchLink({
url: `${process.env.EXPO_PUBLIC_SERVER_URL}/trpc`, url: `${process.env.EXPO_PUBLIC_SERVER_URL}/trpc`,
{{#if auth}}
headers() { headers() {
const headers = new Map<string, string>(); const headers = new Map<string, string>();
const cookies = authClient.getCookie(); const cookies = authClient.getCookie();
@@ -18,6 +21,7 @@ const trpcClient = createTRPCClient<AppRouter>({
} }
return Object.fromEntries(headers); return Object.fromEntries(headers);
}, },
{{/if}}
}), }),
], ],
}); });

View File

@@ -3,7 +3,7 @@
import { QueryCache, QueryClient } from '@tanstack/react-query'; import { QueryCache, QueryClient } from '@tanstack/react-query';
import { createTRPCClient, httpBatchLink } from '@trpc/client'; import { createTRPCClient, httpBatchLink } from '@trpc/client';
import { createTRPCOptionsProxy } from '@trpc/tanstack-react-query'; import { createTRPCOptionsProxy } from '@trpc/tanstack-react-query';
import type { AppRouter } from '../../../server/src/routers'; {{! Adjust path if necessary }} import type { AppRouter } from '../../../server/src/routers';
import { toast } from 'sonner'; import { toast } from 'sonner';
export const queryClient = new QueryClient({ export const queryClient = new QueryClient({

View File

@@ -5,11 +5,22 @@ import { ScrollView, Text, TouchableOpacity, View } from "react-native";
import { Container } from "@/components/container"; import { Container } from "@/components/container";
import { SignIn } from "@/components/sign-in"; import { SignIn } from "@/components/sign-in";
import { SignUp } from "@/components/sign-up"; 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"; import { queryClient, trpc } from "@/utils/trpc";
{{/if}}
export default function Home() { 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 healthCheck = useQuery(trpc.healthCheck.queryOptions());
const privateData = useQuery(trpc.privateData.queryOptions()); const privateData = useQuery(trpc.privateData.queryOptions());
{{/if}}
const { data: session } = authClient.useSession(); const { data: session } = authClient.useSession();
return ( return (

View File

@@ -1,5 +1,10 @@
import { authClient } from "@/lib/auth-client"; import { authClient } from "@/lib/auth-client";
{{#if (eq api "trpc")}}
import { queryClient } from "@/utils/trpc"; import { queryClient } from "@/utils/trpc";
{{/if}}
{{#if (eq api "orpc")}}
import { queryClient } from "@/utils/orpc";
{{/if}}
import { useState } from "react"; import { useState } from "react";
import { import {
ActivityIndicator, ActivityIndicator,

View File

@@ -1,5 +1,10 @@
import { authClient } from "@/lib/auth-client"; import { authClient } from "@/lib/auth-client";
{{#if (eq api "trpc")}}
import { queryClient } from "@/utils/trpc"; import { queryClient } from "@/utils/trpc";
{{/if}}
{{#if (eq api "orpc")}}
import { queryClient } from "@/utils/orpc";
{{/if}}
import { useState } from "react"; import { useState } from "react";
import { import {
ActivityIndicator, ActivityIndicator,

View File

@@ -1,6 +1,9 @@
{{#if (eq orm "prisma")}} {{#if (eq orm "prisma")}}
import { betterAuth } from "better-auth"; import { betterAuth } from "better-auth";
import { prismaAdapter } from "better-auth/adapters/prisma"; import { prismaAdapter } from "better-auth/adapters/prisma";
{{#if (includes frontend "native")}}
import { expo } from "@better-auth/expo";
{{/if}}
import prisma from "../../prisma"; import prisma from "../../prisma";
export const auth = betterAuth({ export const auth = betterAuth({
@@ -9,14 +12,27 @@ export const auth = betterAuth({
{{#if (eq database "sqlite")}}provider: "sqlite"{{/if}} {{#if (eq database "sqlite")}}provider: "sqlite"{{/if}}
{{#if (eq database "mysql")}}provider: "mysql"{{/if}} {{#if (eq database "mysql")}}provider: "mysql"{{/if}}
}), }),
trustedOrigins: [process.env.CORS_ORIGIN || ""], trustedOrigins: [
emailAndPassword: { enabled: true } process.env.CORS_ORIGIN || "",{{#if (includes frontend "native")}}
"my-better-t-app://",{{/if}}
],
emailAndPassword: {
enabled: true,
}
{{~#if (includes frontend "native")}}
,
plugins: [expo()]
{{/if~}}
}); });
{{/if}} {{/if}}
{{#if (eq orm "drizzle")}} {{#if (eq orm "drizzle")}}
import { betterAuth } from "better-auth"; import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle"; import { drizzleAdapter } from "better-auth/adapters/drizzle";
{{#if (includes frontend "native")}}
import { expo } from "@better-auth/expo";
{{/if}}
import { db } from "../db"; import { db } from "../db";
import * as schema from "../db/schema/auth"; import * as schema from "../db/schema/auth";
@@ -25,19 +41,42 @@ export const auth = betterAuth({
{{#if (eq database "postgres")}}provider: "pg",{{/if}} {{#if (eq database "postgres")}}provider: "pg",{{/if}}
{{#if (eq database "sqlite")}}provider: "sqlite",{{/if}} {{#if (eq database "sqlite")}}provider: "sqlite",{{/if}}
{{#if (eq database "mysql")}}provider: "mysql",{{/if}} {{#if (eq database "mysql")}}provider: "mysql",{{/if}}
schema: schema schema: schema,
}), }),
trustedOrigins: [process.env.CORS_ORIGIN || ""], trustedOrigins: [
emailAndPassword: { enabled: true } process.env.CORS_ORIGIN || "",{{#if (includes frontend "native")}}
"my-better-t-app://",{{/if}}
],
emailAndPassword: {
enabled: true,
}
{{~#if (includes frontend "native")}}
,
plugins: [expo()]
{{/if~}}
}); });
{{/if}} {{/if}}
{{#if (eq orm "none")}} {{#if (eq orm "none")}}
import { betterAuth } from "better-auth"; import { betterAuth } from "better-auth";
{{#if (includes frontend "native")}}
import { expo } from "@better-auth/expo";
{{/if}}
export const auth = betterAuth({ export const auth = betterAuth({
database: "", database: "", // Invalid configuration
trustedOrigins: [process.env.CORS_ORIGIN || ""], trustedOrigins: [
emailAndPassword: { enabled: true } process.env.CORS_ORIGIN || "",{{#if (includes frontend "native")}}
"my-better-t-app://", // Use hardcoded scheme{{/if}}
],
emailAndPassword: {
enabled: true,
}
{{~#if (includes frontend "native")}}
,
plugins: [expo()]
{{/if~}}
}); });
{{/if}} {{/if}}

View File

@@ -0,0 +1,78 @@
<script setup lang="ts">
import { z } from 'zod'
// import { authClient } from "~/lib/auth-client";
const {$authClient} = useNuxtApp()
import type { FormSubmitEvent } from '#ui/types'
const emit = defineEmits(['switchToSignUp'])
const toast = useToast()
const loading = ref(false)
const schema = z.object({
email: z.string().email('Invalid email address'),
password: z.string().min(6, 'Password must be at least 6 characters'),
})
type Schema = z.output<typeof schema>
const state = reactive({
email: '',
password: '',
})
async function onSubmit (event: FormSubmitEvent<Schema>) {
loading.value = true
try {
await $authClient.signIn.email(
{
email: event.data.email,
password: event.data.password,
},
{
onSuccess: () => {
toast.add({ title: 'Sign in successful' })
navigateTo('/dashboard', { replace: true })
},
onError: (error) => {
toast.add({ title: 'Sign in failed', description: error.error.message })
},
},
)
} catch (error: any) {
toast.add({ title: 'An unexpected error occurred', description: error.message || 'Please try again.' })
} finally {
loading.value = false
}
}
</script>
<template>
<div class="mx-auto w-full mt-10 max-w-md p-6">
<h1 class="mb-6 text-center text-3xl font-bold">Welcome Back</h1>
<UForm :schema="schema" :state="state" class="space-y-4" @submit="onSubmit">
<UFormField label="Email" name="email">
<UInput v-model="state.email" type="email" class="w-full" />
</UFormField>
<UFormField label="Password" name="password">
<UInput v-model="state.password" type="password" class="w-full" />
</UFormField>
<UButton type="submit" block :loading="loading">
Sign In
</UButton>
</UForm>
<div class="mt-4 text-center">
<UButton
variant="link"
@click="$emit('switchToSignUp')"
class="text-primary hover:text-primary-dark"
>
Need an account? Sign Up
</UButton>
</div>
</div>
</template>

View File

@@ -0,0 +1,85 @@
<script setup lang="ts">
import { z } from 'zod'
import type { FormSubmitEvent } from '#ui/types'
// import { authClient } from "~/lib/auth-client";
const {$authClient} = useNuxtApp()
const emit = defineEmits(['switchToSignIn'])
const toast = useToast()
const loading = ref(false)
const schema = z.object({
name: z.string().min(2, 'Name must be at least 2 characters'),
email: z.string().email('Invalid email address'),
password: z.string().min(6, 'Password must be at least 6 characters'),
})
type Schema = z.output<typeof schema>
const state = reactive({
name: '',
email: '',
password: '',
})
async function onSubmit (event: FormSubmitEvent<Schema>) {
loading.value = true
try {
await $authClient.signUp.email(
{
name: event.data.name,
email: event.data.email,
password: event.data.password,
},
{
onSuccess: () => {
toast.add({ title: 'Sign up successful' })
navigateTo('/dashboard', { replace: true })
},
onError: (error) => {
toast.add({ title: 'Sign up failed', description: error.error.message })
},
},
)
} catch (error: any) {
toast.add({ title: 'An unexpected error occurred', description: error.message || 'Please try again.' })
} finally {
loading.value = false
}
}
</script>
<template>
<div class="mx-auto w-full mt-10 max-w-md p-6">
<h1 class="mb-6 text-center text-3xl font-bold">Create Account</h1>
<UForm :schema="schema" :state="state" class="space-y-4" @submit="onSubmit">
<UFormField label="Name" name="name">
<UInput v-model="state.name" class="w-full" />
</UFormField>
<UFormField label="Email" name="email">
<UInput v-model="state.email" type="email" class="w-full" />
</UFormField>
<UFormField label="Password" name="password">
<UInput v-model="state.password" type="password" class="w-full" />
</UFormField>
<UButton type="submit" block :loading="loading">
Sign Up
</UButton>
</UForm>
<div class="mt-4 text-center">
<UButton
variant="link"
@click="$emit('switchToSignIn')"
class="text-primary hover:text-primary-dark"
>
Already have an account? Sign In
</UButton>
</div>
</div>
</template>

View File

@@ -0,0 +1,43 @@
<script setup lang="ts">
// import { authClient } from "~/lib/auth-client";
const {$authClient} = useNuxtApp()
const session = $authClient.useSession()
const toast = useToast()
const handleSignOut = async () => {
try {
await $authClient.signOut({
fetchOptions: {
onSuccess: async () => {
toast.add({ title: 'Signed out successfully' })
await navigateTo('/', { replace: true, external: true })
},
onError: (error) => {
toast.add({ title: 'Sign out failed', description: error?.error?.message || 'Unknown error'})
}
},
})
} catch (error: any) {
toast.add({ title: 'An unexpected error occurred during sign out', description: error.message || 'Please try again.'})
}
}
</script>
<template>
<div>
<USkeleton v-if="session.isPending" class="h-9 w-24" />
<UButton v-else-if="!session.data" variant="outline" to="/login">
Sign In
</UButton>
<UButton
v-else
variant="solid"
icon="i-lucide-log-out"
label="Sign out"
@click="handleSignOut()"
/>
</div>
</template>

View File

@@ -0,0 +1,12 @@
export default defineNuxtRouteMiddleware(async (to, from) => {
if (import.meta.server) return
const { $authClient } = useNuxtApp()
const session = $authClient.useSession()
if (session.value.isPending || !session.value) {
if (to.path === "/dashboard") {
return navigateTo("/login");
}
}
});

View File

@@ -0,0 +1,27 @@
<script setup lang="ts">
import { useQuery } from '@tanstack/vue-query'
const {$authClient} = useNuxtApp()
definePageMeta({
middleware: ['auth']
})
const { $orpc } = useNuxtApp()
const session = $authClient.useSession()
const privateData = useQuery($orpc.privateData.queryOptions())
</script>
<template>
<div class="container mx-auto p-4">
<h1 class="text-2xl font-bold mb-4">Dashboard</h1>
<div v-if="session?.data?.user">
<p class="mb-2">Welcome {{ session.data.user.name }}</p>
</div>
<div v-if="privateData.status.value === 'pending'">Loading private data...</div>
<div v-else-if="privateData.status.value === 'error'">Error loading private data: {{ privateData.error.value?.message }}</div>
<p v-else-if="privateData.data.value">Private Data: {{ privateData.data.value.message }}</p>
</div>
</template>

View File

@@ -0,0 +1,24 @@
<script setup lang="ts">
const { $authClient } = useNuxtApp();
import SignInForm from "~/components/SignInForm.vue";
import SignUpForm from "~/components/SignUpForm.vue";
const session = $authClient.useSession();
const showSignIn = ref(true);
watchEffect(() => {
if (!session?.value.isPending && session?.value.data) {
navigateTo("/dashboard", { replace: true });
}
});
</script>
<template>
<div>
<Loader v-if="session.isPending" />
<div v-else-if="!session.data">
<SignInForm v-if="showSignIn" @switch-to-sign-up="showSignIn = false" />
<SignUpForm v-else @switch-to-sign-in="showSignIn = true" />
</div>
</div>
</template>

View File

@@ -0,0 +1,16 @@
import { createAuthClient } from "better-auth/vue";
export default defineNuxtPlugin(nuxtApp => {
const config = useRuntimeConfig()
const serverUrl = config.public.serverURL
const authClient = createAuthClient({
baseURL: serverUrl
})
return {
provide: {
authClient: authClient
}
}
})

View File

@@ -0,0 +1,64 @@
<script setup lang="ts">
import { ref, watch, nextTick } from 'vue'
import { useChat } from '@ai-sdk/vue'
const config = useRuntimeConfig()
const serverUrl = config.public.serverURL
const { messages, input, handleSubmit } = useChat({
api: `${serverUrl}/ai`,
})
const messagesEndRef = ref<null | HTMLDivElement>(null)
watch(messages, async () => {
await nextTick()
messagesEndRef.value?.scrollIntoView({ behavior: 'smooth' })
})
// Helper: Concatenate all text parts for a message
function getMessageText(message: any) {
return message.parts
.filter((part: any) => part.type === 'text')
.map((part: any) => part.text)
.join('')
}
</script>
<template>
<div class="grid grid-rows-[1fr_auto] overflow-hidden w-full mx-auto p-4">
<div class="overflow-y-auto space-y-4 pb-4">
<div v-if="messages.length === 0" class="text-center text-muted-foreground mt-8">
Ask me anything to get started!
</div>
<div
v-for="message in messages"
:key="message.id"
:class="[
'p-3 rounded-lg',
message.role === 'user' ? 'bg-primary/10 ml-8' : 'bg-secondary/20 mr-8'
]"
>
<p class="text-sm font-semibold mb-1">
{{ message.role === 'user' ? 'You' : 'AI Assistant' }}
</p>
<div class="whitespace-pre-wrap">{{ getMessageText(message) }}</div>
</div>
<div ref="messagesEndRef" />
</div>
<form @submit.prevent="handleSubmit" class="w-full flex items-center space-x-2 pt-2 border-t">
<UInput
name="prompt"
v-model="input"
placeholder="Type your message..."
class="flex-1"
autocomplete="off"
autofocus
/>
<UButton type="submit" color="primary" size="md" square>
<UIcon name="i-lucide-send" class="w-5 h-5" />
</UButton>
</form>
</div>
</template>

View File

@@ -0,0 +1,108 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useQuery, useMutation, useQueryClient } from '@tanstack/vue-query'
const { $orpc } = useNuxtApp()
const newTodoText = ref('')
const queryClient = useQueryClient()
const todos = useQuery($orpc.todo.getAll.queryOptions())
const createMutation = useMutation($orpc.todo.create.mutationOptions({
onSuccess: () => {
queryClient.invalidateQueries()
newTodoText.value = ''
}
}))
const toggleMutation = useMutation($orpc.todo.toggle.mutationOptions({
onSuccess: () => queryClient.invalidateQueries()
}))
const deleteMutation = useMutation($orpc.todo.delete.mutationOptions({
onSuccess: () => queryClient.invalidateQueries()
}))
function handleAddTodo() {
if (newTodoText.value.trim()) {
createMutation.mutate({ text: newTodoText.value })
}
}
function handleToggleTodo(id: number, completed: boolean) {
toggleMutation.mutate({ id, completed: !completed })
}
function handleDeleteTodo(id: number) {
deleteMutation.mutate({ id })
}
</script>
<template>
<div class="mx-auto w-full max-w-md py-10">
<UCard>
<template #header>
<div>
<div class="text-xl font-bold">Todo List</div>
<div class="text-muted text-sm">Manage your tasks efficiently</div>
</div>
</template>
<form @submit.prevent="handleAddTodo" class="mb-6 flex items-center gap-2">
<UInput
v-model="newTodoText"
placeholder="Add a new task..."
autocomplete="off"
class="w-full"
/>
<UButton
type="submit"
icon="i-lucide-plus"
>
Add
</UButton>
</form>
<div v-if="todos.status.value === 'pending'" class="flex justify-center py-4">
<UIcon name="i-lucide-loader-2" class="animate-spin w-6 h-6" />
</div>
<p v-else-if="todos.status.value === 'error'" class="py-4 text-center text-red-500">
Error: {{ todos.error.value?.message || 'Failed to load todos' }}
</p>
<p v-else-if="todos.data.value?.length === 0" class="py-4 text-center">
No todos yet. Add one above!
</p>
<ul v-else class="space-y-2">
<li
v-for="todo in todos.data.value"
:key="todo.id"
class="flex items-center justify-between rounded-md border p-2"
>
<div class="flex items-center gap-2">
<UCheckbox
:model-value="todo.completed"
@update:model-value="() => handleToggleTodo(todo.id, todo.completed)"
:id="`todo-${todo.id}`"
/>
<label
:for="`todo-${todo.id}`"
:class="{ 'line-through text-muted': todo.completed }"
class="cursor-pointer"
>
{{ todo.text }}
</label>
</div>
<UButton
color="neutral"
variant="ghost"
size="sm"
square
@click="handleDeleteTodo(todo.id)"
aria-label="Delete todo"
icon="i-lucide-trash-2"
/>
</li>
</ul>
</UCard>
</div>
</template>

View File

@@ -1 +0,0 @@
node-linker=hoisted

View File

@@ -0,0 +1,5 @@
node-linker=hoisted
{{#if (includes frontend "nuxt")}}
shamefully-hoist=true
strict-peer-dependencies=false
{{/if}}

View File

@@ -1,10 +1,20 @@
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { View, Text, ScrollView } from "react-native"; import { View, Text, ScrollView } from "react-native";
import { Container } from "@/components/container"; import { Container } from "@/components/container";
{{#if (eq api "orpc")}}
import { orpc } from "@/utils/orpc";
{{/if}}
{{#if (eq api "trpc")}}
import { trpc } from "@/utils/trpc"; import { trpc } from "@/utils/trpc";
{{/if}}
export default function Home() { 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()); const healthCheck = useQuery(trpc.healthCheck.queryOptions());
{{/if}}
return ( return (
<Container> <Container>

View File

@@ -9,7 +9,12 @@ import {
import { StatusBar } from "expo-status-bar"; import { StatusBar } from "expo-status-bar";
import { GestureHandlerRootView } from "react-native-gesture-handler"; import { GestureHandlerRootView } from "react-native-gesture-handler";
import "../global.css"; import "../global.css";
{{#if (eq api "trpc")}}
import { queryClient } from "@/utils/trpc"; import { queryClient } from "@/utils/trpc";
{{/if}}
{{#if (eq api "orpc")}}
import { queryClient } from "@/utils/orpc";
{{/if}}
import { NAV_THEME } from "@/lib/constants"; import { NAV_THEME } from "@/lib/constants";
import React, { useRef } from "react"; import React, { useRef } from "react";
import { useColorScheme } from "@/lib/use-color-scheme"; import { useColorScheme } from "@/lib/use-color-scheme";
@@ -56,12 +61,12 @@ export default function RootLayout() {
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<ThemeProvider value={isDarkColorScheme ? DARK_THEME : LIGHT_THEME}> <ThemeProvider value={isDarkColorScheme ? DARK_THEME : LIGHT_THEME}>
<StatusBar style={isDarkColorScheme ? "light" : "dark"} /> <StatusBar style={isDarkColorScheme ? "light" : "dark"} />
<GestureHandlerRootView style={{ flex: 1 }}> <GestureHandlerRootView style=\{{ flex: 1 }}>
<Stack> <Stack>
<Stack.Screen name="(drawer)" options={{ headerShown: false }} /> <Stack.Screen name="(drawer)" options=\{{ headerShown: false }} />
<Stack.Screen <Stack.Screen
name="modal" name="modal"
options={{ title: "Modal", presentation: "modal" }} options=\{{ title: "Modal", presentation: "modal" }}
/> />
</Stack> </Stack>
</GestureHandlerRootView> </GestureHandlerRootView>

View File

@@ -16,9 +16,6 @@
"@react-navigation/native": "^7.0.14", "@react-navigation/native": "^7.0.14",
"@tanstack/react-form": "^1.0.5", "@tanstack/react-form": "^1.0.5",
"@tanstack/react-query": "^5.69.2", "@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.44", "expo": "^52.0.44",
"expo-constants": "~17.0.8", "expo-constants": "~17.0.8",
"expo-linking": "~7.0.5", "expo-linking": "~7.0.5",

View File

@@ -1,19 +0,0 @@
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

@@ -0,0 +1,24 @@
# Nuxt dev/build outputs
.output
.data
.nuxt
.nitro
.cache
dist
# Node dependencies
node_modules
# Logs
logs
*.log
# Misc
.DS_Store
.fleet
.idea
# Local env files
.env
.env.*
!.env.example

View File

@@ -0,0 +1,15 @@
export default defineAppConfig({
// https://ui.nuxt.com/getting-started/theme#design-system
ui: {
colors: {
primary: 'emerald',
neutral: 'slate',
},
button: {
defaultVariants: {
// Set default button color to neutral
// color: 'neutral'
}
}
}
})

View File

@@ -0,0 +1,13 @@
<script setup lang="ts">
import { VueQueryDevtools } from '@tanstack/vue-query-devtools'
</script>
<template>
<NuxtLoadingIndicator />
<UApp>
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
</UApp>
<VueQueryDevtools />
</template>

View File

@@ -0,0 +1,2 @@
@import "tailwindcss";
@import "@nuxt/ui";

View File

@@ -0,0 +1,45 @@
<script setup lang="ts">
import { USeparator } from '#components';
import ModeToggle from './ModeToggle.vue'
{{#if auth}}
import UserMenu from './UserMenu.vue'
{{/if}}
const links = [
{ to: "/", label: "Home" },
{{#if auth}}
{ to: "/dashboard", label: "Dashboard" },
{{/if}}
{{#if (includes examples "todo")}}
{ to: "/todos", label: "Todos" },
{{/if}}
{{#if (includes examples "ai")}}
{ to: "/ai", label: "AI Chat" },
{{/if}}
];
</script>
<template>
<div>
<div class="flex flex-row items-center justify-between px-2 py-1">
<nav class="flex gap-4 text-lg">
<NuxtLink
v-for="link in links"
:key="link.to"
:to="link.to"
class="text-foreground hover:text-primary"
active-class="text-primary font-semibold"
>
\{{ link.label }}
</NuxtLink>
</nav>
<div class="flex items-center gap-2">
<ModeToggle />
{{#if auth}}
<UserMenu />
{{/if}}
</div>
</div>
<USeparator />
</div>
</template>

View File

@@ -0,0 +1,5 @@
<template>
<div class="flex h-full items-center justify-center pt-8">
<UIcon name="i-lucide-loader-2" class="animate-spin text-2xl" />
</div>
</template>

View File

@@ -0,0 +1,23 @@
<script setup lang="ts">
const colorMode = useColorMode()
const isDark = computed({
get () {
return colorMode.value === 'dark'
},
set (value) {
colorMode.preference = value ? 'dark' : 'light'
}
})
</script>
<template>
<div class="flex items-center">
<USwitch
v-model="isDark"
:checked-icon="isDark ? 'i-lucide-moon' : ''"
:unchecked-icon="!isDark ? 'i-lucide-sun' : ''"
class="mr-2"
/>
</div>
</template>

View File

@@ -0,0 +1,11 @@
<script setup>
</script>
<template>
<div class="grid grid-rows-[auto_1fr] h-full">
<Header />
<main class="overflow-y-auto">
<slot />
</main>
</div>
</template>

View File

@@ -0,0 +1,63 @@
<script setup lang="ts">
const { $orpc } = useNuxtApp()
import { useQuery } from '@tanstack/vue-query'
const TITLE_TEXT = `
██████╗ ███████╗████████╗████████╗███████╗██████╗
██╔══██╗██╔════╝╚══██╔══╝╚══██╔══╝██╔════╝██╔══██╗
██████╔╝█████╗ ██║ ██║ █████╗ ██████╔╝
██╔══██╗██╔══╝ ██║ ██║ ██╔══╝ ██╔══██╗
██████╔╝███████╗ ██║ ██║ ███████╗██║ ██║
╚═════╝ ╚══════╝ ╚═╝ ╚═╝ ╚══════╝╚═╝ ╚═╝
████████╗ ███████╗████████╗ █████╗ ██████╗██╗ ██╗
╚══██╔══╝ ██╔════╝╚══██╔══╝██╔══██╗██╔════╝██║ ██╔╝
██║ ███████╗ ██║ ███████║██║ █████╔╝
██║ ╚════██║ ██║ ██╔══██║██║ ██╔═██╗
██║ ███████║ ██║ ██║ ██║╚██████╗██║ ██╗
╚═╝ ╚══════╝ ╚═╝ ╚═╝ ╚═╝ ╚═════╝╚═╝ ╚═╝
`;
const healthCheck = useQuery($orpc.healthCheck.queryOptions())
</script>
<template>
<div class="container mx-auto max-w-3xl px-4 py-2">
<pre class="overflow-x-auto font-mono text-sm whitespace-pre-wrap">{{ TITLE_TEXT }}</pre>
<div class="grid gap-6 mt-4">
<section class="rounded-lg border p-4">
<h2 class="mb-2 font-medium">API Status</h2>
<div class="flex items-center gap-2">
<div class="flex items-center gap-2">
<div
class="w-2 h-2 rounded-full"
:class="{
'bg-yellow-500 animate-pulse': healthCheck.status.value === 'pending',
'bg-green-500': healthCheck.status.value === 'success',
'bg-red-500': healthCheck.status.value === 'error',
'bg-gray-400': healthCheck.status.value !== 'pending' &&
healthCheck.status.value !== 'success' &&
healthCheck.status.value !== 'error'
}"
></div>
<span class="text-sm text-muted-foreground">
<template v-if="healthCheck.status.value === 'pending'">
Checking...
</template>
<template v-else-if="healthCheck.status.value === 'success'">
Connected ({{ healthCheck.data.value }})
</template>
<template v-else-if="healthCheck.status.value === 'error'">
Error: {{ healthCheck.error.value?.message || 'Failed to connect' }}
</template>
<template v-else>
Idle
</template>
</span>
</div>
</div>
</section>
</div>
</div>
</template>

View File

@@ -0,0 +1,44 @@
import type {
DehydratedState,
VueQueryPluginOptions,
} from '@tanstack/vue-query'
import {
dehydrate,
hydrate,
QueryCache,
QueryClient,
VueQueryPlugin,
} from '@tanstack/vue-query'
export default defineNuxtPlugin((nuxt) => {
const vueQueryState = useState<DehydratedState | null>('vue-query')
const toast = useToast()
const queryClient = new QueryClient({
queryCache: new QueryCache({
onError: (error) => {
console.log(error)
toast.add({
title: 'Error',
description: error?.message || 'An unexpected error occurred.',
})
},
}),
})
const options: VueQueryPluginOptions = { queryClient }
nuxt.vueApp.use(VueQueryPlugin, options)
if (import.meta.server) {
nuxt.hooks.hook('app:rendered', () => {
vueQueryState.value = dehydrate(queryClient)
})
}
if (import.meta.client) {
nuxt.hooks.hook('app:created', () => {
hydrate(queryClient, vueQueryState.value)
})
}
})

View File

@@ -0,0 +1,19 @@
// https://nuxt.com/docs/api/configuration/nuxt-config
export default defineNuxtConfig({
compatibilityDate: '2024-11-01',
future: {
compatibilityVersion: 4
},
devtools: { enabled: true },
modules: ['@nuxt/ui'],
css: ['~/assets/css/main.css'],
devServer: {
port: 3001
},
ssr: false,
runtimeConfig: {
public: {
serverURL: process.env.NUXT_PUBLIC_SERVER_URL,
}
}
})

View File

@@ -0,0 +1,25 @@
{
"name": "web",
"private": true,
"type": "module",
"scripts": {
"build": "nuxt build",
"dev": "nuxt dev",
"generate": "nuxt generate",
"preview": "nuxt preview",
"postinstall": "nuxt prepare"
},
"dependencies": {
"@nuxt/ui": "3.0.2",
"@tanstack/vue-query": "^5.74.5",
"nuxt": "^3.16.2",
"typescript": "^5.6.3",
"vue": "^3.5.13",
"vue-router": "^4.5.0",
"zod": "^3.24.3"
},
"devDependencies": {
"@tanstack/vue-query-devtools": "^5.74.5",
"@iconify-json/lucide": "^1.2.38"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

View File

@@ -0,0 +1,2 @@
User-Agent: *
Disallow:

View File

@@ -0,0 +1,3 @@
{
"extends": "../.nuxt/tsconfig.server.json"
}

View File

@@ -0,0 +1,4 @@
{
// https://nuxt.com/docs/guide/concepts/typescript
"extends": "./.nuxt/tsconfig.json"
}

View File

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

View File

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Some files were not shown because too many files have changed in this diff Show More