diff --git a/apps/cli/src/constants.ts b/apps/cli/src/constants.ts index 704d94bf..f2ecd65a 100644 --- a/apps/cli/src/constants.ts +++ b/apps/cli/src/constants.ts @@ -45,6 +45,7 @@ export const dependencyVersionMap = { mongoose: "^8.14.0", "vite-plugin-pwa": "^0.21.2", + "@angular/service-worker": "^20.0.2", "@vite-pwa/assets-generator": "^0.2.6", "@tauri-apps/cli": "^2.4.0", @@ -92,6 +93,7 @@ export const dependencyVersionMap = { "@orpc/tanstack-query": "^1.5.0", "@trpc/tanstack-react-query": "^11.0.0", + "@tanstack/angular-query-experimental": "5.80.2", "@trpc/server": "^11.0.0", "@trpc/client": "^11.0.0", diff --git a/apps/cli/src/helpers/project-generation/template-manager.ts b/apps/cli/src/helpers/project-generation/template-manager.ts index 6edaf6b1..b1b08135 100644 --- a/apps/cli/src/helpers/project-generation/template-manager.ts +++ b/apps/cli/src/helpers/project-generation/template-manager.ts @@ -3,6 +3,7 @@ import fs from "fs-extra"; import { globby } from "globby"; import { PKG_ROOT } from "../../constants"; import type { ProjectConfig } from "../../types"; +import { addPackageDependency } from "../../utils/add-package-deps"; import { processTemplate } from "../../utils/template-processor"; async function processAndCopyFiles( @@ -70,12 +71,19 @@ export async function setupFrontendTemplates( const hasNuxtWeb = context.frontend.includes("nuxt"); const hasSvelteWeb = context.frontend.includes("svelte"); const hasSolidWeb = context.frontend.includes("solid"); + const hasAngularWeb = context.frontend.includes("angular"); const hasNativeWind = context.frontend.includes("native-nativewind"); const hasUnistyles = context.frontend.includes("native-unistyles"); const _hasNative = hasNativeWind || hasUnistyles; const isConvex = context.backend === "convex"; - if (hasReactWeb || hasNuxtWeb || hasSvelteWeb || hasSolidWeb) { + if ( + hasReactWeb || + hasNuxtWeb || + hasSvelteWeb || + hasSolidWeb || + hasAngularWeb + ) { const webAppDir = path.join(projectDir, "apps/web"); await fs.ensureDir(webAppDir); @@ -180,6 +188,28 @@ export async function setupFrontendTemplates( } else { } } + } else if (hasAngularWeb) { + const angularBaseDir = path.join(PKG_ROOT, "templates/frontend/angular"); + if (await fs.pathExists(angularBaseDir)) { + await processAndCopyFiles("**/*", angularBaseDir, webAppDir, context); + } else { + } + + if (!isConvex && (context.api === "orpc" || context.api === "trpc")) { + const apiWebAngularDir = path.join( + PKG_ROOT, + `templates/api/${context.api}/web/angular`, + ); + if (await fs.pathExists(apiWebAngularDir)) { + await processAndCopyFiles( + "**/*", + apiWebAngularDir, + webAppDir, + context, + ); + } else { + } + } } } @@ -378,7 +408,7 @@ export async function setupAuthTemplate( const hasNativeWind = context.frontend.includes("native-nativewind"); const hasUnistyles = context.frontend.includes("native-unistyles"); const hasNative = hasNativeWind || hasUnistyles; - + const hasAngularWeb = context.frontend.includes("angular"); if (serverAppDirExists) { const authServerBaseSrc = path.join(PKG_ROOT, "templates/auth/server/base"); if (await fs.pathExists(authServerBaseSrc)) { @@ -435,7 +465,11 @@ export async function setupAuthTemplate( } if ( - (hasReactWeb || hasNuxtWeb || hasSvelteWeb || hasSolidWeb) && + (hasReactWeb || + hasNuxtWeb || + hasSvelteWeb || + hasSolidWeb || + hasAngularWeb) && webAppDirExists ) { if (hasReactWeb) { @@ -503,6 +537,20 @@ export async function setupAuthTemplate( } else { } } + } else if (hasAngularWeb) { + const authWebAngularSrc = path.join( + PKG_ROOT, + "templates/auth/web/angular", + ); + if (await fs.pathExists(authWebAngularSrc)) { + await processAndCopyFiles( + "**/*", + authWebAngularSrc, + webAppDir, + context, + ); + } else { + } } } @@ -570,6 +618,15 @@ export async function setupAddonsTemplate( ) ) { addonSrcDir = path.join(PKG_ROOT, "templates/addons/pwa/apps/web/vite"); + } else if (context.frontend.includes("angular")) { + addonSrcDir = path.join( + PKG_ROOT, + "templates/addons/pwa/apps/web/angular", + ); + await addPackageDependency({ + dependencies: ["@angular/service-worker"], + projectDir: webAppDir, + }); } else { continue; } @@ -608,7 +665,7 @@ export async function setupExamplesTemplate( const hasNuxtWeb = context.frontend.includes("nuxt"); const hasSvelteWeb = context.frontend.includes("svelte"); const hasSolidWeb = context.frontend.includes("solid"); - + const hasAngularWeb = context.frontend.includes("angular"); for (const example of context.examples) { if (example === "none") continue; @@ -758,6 +815,17 @@ export async function setupExamplesTemplate( ); } else { } + } else if (hasAngularWeb) { + const exampleWebAngularSrc = path.join(exampleBaseDir, "web/angular"); + if (await fs.pathExists(exampleWebAngularSrc)) { + await processAndCopyFiles( + "**/*", + exampleWebAngularSrc, + webAppDir, + context, + ); + } else { + } } } diff --git a/apps/cli/src/helpers/setup/api-setup.ts b/apps/cli/src/helpers/setup/api-setup.ts index 058ec0a6..7438951b 100644 --- a/apps/cli/src/helpers/setup/api-setup.ts +++ b/apps/cli/src/helpers/setup/api-setup.ts @@ -19,7 +19,7 @@ export async function setupApi(config: ProjectConfig): Promise { const hasNuxtWeb = frontend.includes("nuxt"); const hasSvelteWeb = frontend.includes("svelte"); const hasSolidWeb = frontend.includes("solid"); - + const hasAngularWeb = frontend.includes("angular"); if (!isConvex && api !== "none") { const serverDir = path.join(projectDir, "apps/server"); const serverDirExists = await fs.pathExists(serverDir); @@ -106,6 +106,22 @@ export async function setupApi(config: ProjectConfig): Promise { projectDir: webDir, }); } + } else if (hasAngularWeb) { + if (api === "trpc") { + await addPackageDependency({ + dependencies: ["@trpc/client", "@trpc/server"], + projectDir: webDir, + }); + } else if (api === "orpc") { + await addPackageDependency({ + dependencies: [ + "@orpc/tanstack-query", + "@orpc/client", + "@orpc/server", + ], + projectDir: webDir, + }); + } } } @@ -142,7 +158,7 @@ export async function setupApi(config: ProjectConfig): Promise { ]; const needsSolidQuery = frontend.includes("solid"); const needsReactQuery = frontend.some((f) => reactBasedFrontends.includes(f)); - + const needsAngularQuery = frontend.includes("angular"); if (needsReactQuery && !isConvex) { const reactQueryDeps: AvailableDependencies[] = ["@tanstack/react-query"]; const reactQueryDevDeps: AvailableDependencies[] = [ @@ -206,7 +222,17 @@ export async function setupApi(config: ProjectConfig): Promise { } } } - + if (needsAngularQuery && !isConvex) { + if (webDirExists) { + const webPkgJsonPath = path.join(webDir, "package.json"); + if (await fs.pathExists(webPkgJsonPath)) { + await addPackageDependency({ + dependencies: ["@tanstack/angular-query-experimental"], + projectDir: webDir, + }); + } + } + } if (isConvex) { if (webDirExists) { const webPkgJsonPath = path.join(webDir, "package.json"); diff --git a/apps/cli/src/prompts/addons.ts b/apps/cli/src/prompts/addons.ts index 7e5c31bd..bcfc326b 100644 --- a/apps/cli/src/prompts/addons.ts +++ b/apps/cli/src/prompts/addons.ts @@ -19,7 +19,8 @@ export async function getAddonsChoice( frontends?.includes("react-router") || frontends?.includes("tanstack-router") || frontends?.includes("solid") || - frontends?.includes("next"); + frontends?.includes("next") || + frontends?.includes("angular"); const hasCompatibleTauriFrontend = frontends?.includes("react-router") || @@ -27,7 +28,8 @@ export async function getAddonsChoice( frontends?.includes("nuxt") || frontends?.includes("svelte") || frontends?.includes("solid") || - frontends?.includes("next"); + frontends?.includes("next") || + frontends?.includes("angular"); const allPossibleOptions: AddonOption[] = [ { diff --git a/apps/cli/src/prompts/api.ts b/apps/cli/src/prompts/api.ts index 48d12b3b..e937049e 100644 --- a/apps/cli/src/prompts/api.ts +++ b/apps/cli/src/prompts/api.ts @@ -16,6 +16,7 @@ export async function getApiChoice( const includesNuxt = frontend?.includes("nuxt"); const includesSvelte = frontend?.includes("svelte"); const includesSolid = frontend?.includes("solid"); + const includesAngular = frontend?.includes("angular"); let apiOptions = [ { @@ -35,7 +36,7 @@ export async function getApiChoice( }, ]; - if (includesNuxt || includesSvelte || includesSolid) { + if (includesNuxt || includesSvelte || includesSolid || includesAngular) { apiOptions = [ { value: "orpc" as const, diff --git a/apps/cli/src/prompts/backend.ts b/apps/cli/src/prompts/backend.ts index d9bf2719..2c12f8a7 100644 --- a/apps/cli/src/prompts/backend.ts +++ b/apps/cli/src/prompts/backend.ts @@ -10,7 +10,7 @@ export async function getBackendFrameworkChoice( if (backendFramework !== undefined) return backendFramework; const hasIncompatibleFrontend = frontends?.some( - (f) => f === "nuxt" || f === "solid", + (f) => f === "nuxt" || f === "solid" || f === "angular", ); const backendOptions: Array<{ diff --git a/apps/cli/src/prompts/examples.ts b/apps/cli/src/prompts/examples.ts index 3d39ada8..0eddf5b1 100644 --- a/apps/cli/src/prompts/examples.ts +++ b/apps/cli/src/prompts/examples.ts @@ -38,7 +38,11 @@ export async function getExamplesChoice( }, ]; - if (backend !== "elysia" && !frontends?.includes("solid")) { + if ( + backend !== "elysia" && + !frontends?.includes("solid") && + !frontends?.includes("angular") + ) { options.push({ value: "ai" as const, label: "AI Chat", diff --git a/apps/cli/src/prompts/frontend.ts b/apps/cli/src/prompts/frontend.ts index 893ef4e4..b1c5a764 100644 --- a/apps/cli/src/prompts/frontend.ts +++ b/apps/cli/src/prompts/frontend.ts @@ -71,6 +71,11 @@ export async function getFrontendChoice( label: "TanStack Start (devinxi)", hint: "SSR, Server Functions, API Routes and more with TanStack Router", }, + { + value: "angular" as const, + label: "Angular", + hint: "The web framework that empowers developers to build fast, reliable applications", + }, ]; const webOptions = allWebOptions.filter((option) => { diff --git a/apps/cli/src/types.ts b/apps/cli/src/types.ts index 72d90bfc..d1379ffa 100644 --- a/apps/cli/src/types.ts +++ b/apps/cli/src/types.ts @@ -31,6 +31,7 @@ export const FrontendSchema = z "native-unistyles", "svelte", "solid", + "angular", "none", ]) .describe("Frontend framework"); diff --git a/apps/cli/src/utils/template-processor.ts b/apps/cli/src/utils/template-processor.ts index aaf70095..9acc655b 100644 --- a/apps/cli/src/utils/template-processor.ts +++ b/apps/cli/src/utils/template-processor.ts @@ -32,6 +32,8 @@ handlebars.registerHelper("or", (a, b) => a || b); handlebars.registerHelper("eq", (a, b) => a === b); +handlebars.registerHelper("not", (a) => !a); + handlebars.registerHelper( "includes", (array, value) => Array.isArray(array) && array.includes(value), diff --git a/apps/cli/src/validation.ts b/apps/cli/src/validation.ts index 094a6905..0345bbfb 100644 --- a/apps/cli/src/validation.ts +++ b/apps/cli/src/validation.ts @@ -128,7 +128,8 @@ export function processAndValidateFlags( f === "next" || f === "nuxt" || f === "svelte" || - f === "solid", + f === "solid" || + f === "angular", ); const nativeFrontends = validOptions.filter( (f) => f === "native-nativewind" || f === "native-unistyles", @@ -206,7 +207,7 @@ export function processAndValidateFlags( if (providedFlags.has("frontend") && options.frontend) { const incompatibleFrontends = options.frontend.filter( - (f) => f === "nuxt" || f === "solid", + (f) => f === "nuxt" || f === "solid" || f === "angular", ); if (incompatibleFrontends.length > 0) { consola.fatal( @@ -372,16 +373,28 @@ export function validateConfigCompatibility( const includesNuxt = effectiveFrontend?.includes("nuxt"); const includesSvelte = effectiveFrontend?.includes("svelte"); const includesSolid = effectiveFrontend?.includes("solid"); - + const includesAngular = effectiveFrontend?.includes("angular"); if ( - (includesNuxt || includesSvelte || includesSolid) && + (includesNuxt || includesSvelte || includesSolid || includesAngular) && effectiveApi === "trpc" ) { consola.fatal( `tRPC API is not supported with '${ - includesNuxt ? "nuxt" : includesSvelte ? "svelte" : "solid" + includesNuxt + ? "nuxt" + : includesSvelte + ? "svelte" + : includesSolid + ? "solid" + : "angular" }' frontend. Please use --api orpc or --api none or remove '${ - includesNuxt ? "nuxt" : includesSvelte ? "svelte" : "solid" + includesNuxt + ? "nuxt" + : includesSvelte + ? "svelte" + : includesSolid + ? "solid" + : "angular" }' from --frontend.`, ); process.exit(1); @@ -397,14 +410,16 @@ export function validateConfigCompatibility( f === "tanstack-router" || f === "react-router" || f === "solid" || - f === "next"; + f === "next" || + f === "angular"; const isTauriCompatible = f === "tanstack-router" || f === "react-router" || f === "nuxt" || f === "svelte" || f === "solid" || - f === "next"; + f === "next" || + f === "angular"; if (config.addons?.includes("pwa") && config.addons?.includes("tauri")) { return isPwaCompatible && isTauriCompatible; @@ -422,11 +437,11 @@ export function validateConfigCompatibility( let incompatibleReason = "Selected frontend is not compatible."; if (config.addons.includes("pwa")) { incompatibleReason = - "PWA requires tanstack-router, react-router, next, or solid."; + "PWA requires tanstack-router, react-router, next, angular, or solid."; } if (config.addons.includes("tauri")) { incompatibleReason = - "Tauri requires tanstack-router, react-router, nuxt, svelte, solid, or next."; + "Tauri requires tanstack-router, react-router, nuxt, svelte, solid, next, or angular."; } consola.fatal( `Incompatible addon/frontend combination: ${incompatibleReason}`, @@ -472,6 +487,12 @@ export function validateConfigCompatibility( ); process.exit(1); } + if (config.examples.includes("ai") && includesAngular) { + consola.fatal( + "The 'ai' example is not compatible with the Angular frontend.", + ); + process.exit(1); + } } } diff --git a/apps/cli/templates/addons/pwa/apps/web/angular/ngsw-config.json b/apps/cli/templates/addons/pwa/apps/web/angular/ngsw-config.json new file mode 100644 index 00000000..69edd287 --- /dev/null +++ b/apps/cli/templates/addons/pwa/apps/web/angular/ngsw-config.json @@ -0,0 +1,30 @@ +{ + "$schema": "./node_modules/@angular/service-worker/config/schema.json", + "index": "/index.html", + "assetGroups": [ + { + "name": "app", + "installMode": "prefetch", + "resources": { + "files": [ + "/favicon.ico", + "/index.csr.html", + "/index.html", + "/manifest.webmanifest", + "/*.css", + "/*.js" + ] + } + }, + { + "name": "assets", + "installMode": "lazy", + "updateMode": "prefetch", + "resources": { + "files": [ + "/**/*.(svg|cur|jpg|jpeg|png|apng|webp|avif|gif|otf|ttf|woff|woff2)" + ] + } + } + ] +} diff --git a/apps/cli/templates/addons/pwa/apps/web/angular/public/icons/icon-128x128.png b/apps/cli/templates/addons/pwa/apps/web/angular/public/icons/icon-128x128.png new file mode 100644 index 00000000..5a9a2ccd Binary files /dev/null and b/apps/cli/templates/addons/pwa/apps/web/angular/public/icons/icon-128x128.png differ diff --git a/apps/cli/templates/addons/pwa/apps/web/angular/public/icons/icon-144x144.png b/apps/cli/templates/addons/pwa/apps/web/angular/public/icons/icon-144x144.png new file mode 100644 index 00000000..11702cd7 Binary files /dev/null and b/apps/cli/templates/addons/pwa/apps/web/angular/public/icons/icon-144x144.png differ diff --git a/apps/cli/templates/addons/pwa/apps/web/angular/public/icons/icon-152x152.png b/apps/cli/templates/addons/pwa/apps/web/angular/public/icons/icon-152x152.png new file mode 100644 index 00000000..ff4e06b8 Binary files /dev/null and b/apps/cli/templates/addons/pwa/apps/web/angular/public/icons/icon-152x152.png differ diff --git a/apps/cli/templates/addons/pwa/apps/web/angular/public/icons/icon-192x192.png b/apps/cli/templates/addons/pwa/apps/web/angular/public/icons/icon-192x192.png new file mode 100644 index 00000000..afd36a48 Binary files /dev/null and b/apps/cli/templates/addons/pwa/apps/web/angular/public/icons/icon-192x192.png differ diff --git a/apps/cli/templates/addons/pwa/apps/web/angular/public/icons/icon-384x384.png b/apps/cli/templates/addons/pwa/apps/web/angular/public/icons/icon-384x384.png new file mode 100644 index 00000000..613ac793 Binary files /dev/null and b/apps/cli/templates/addons/pwa/apps/web/angular/public/icons/icon-384x384.png differ diff --git a/apps/cli/templates/addons/pwa/apps/web/angular/public/icons/icon-512x512.png b/apps/cli/templates/addons/pwa/apps/web/angular/public/icons/icon-512x512.png new file mode 100644 index 00000000..7574990f Binary files /dev/null and b/apps/cli/templates/addons/pwa/apps/web/angular/public/icons/icon-512x512.png differ diff --git a/apps/cli/templates/addons/pwa/apps/web/angular/public/icons/icon-72x72.png b/apps/cli/templates/addons/pwa/apps/web/angular/public/icons/icon-72x72.png new file mode 100644 index 00000000..033724e1 Binary files /dev/null and b/apps/cli/templates/addons/pwa/apps/web/angular/public/icons/icon-72x72.png differ diff --git a/apps/cli/templates/addons/pwa/apps/web/angular/public/icons/icon-96x96.png b/apps/cli/templates/addons/pwa/apps/web/angular/public/icons/icon-96x96.png new file mode 100644 index 00000000..3090dc2d Binary files /dev/null and b/apps/cli/templates/addons/pwa/apps/web/angular/public/icons/icon-96x96.png differ diff --git a/apps/cli/templates/addons/pwa/apps/web/angular/public/manifest.webmanifest.hbs b/apps/cli/templates/addons/pwa/apps/web/angular/public/manifest.webmanifest.hbs new file mode 100644 index 00000000..a3a66e13 --- /dev/null +++ b/apps/cli/templates/addons/pwa/apps/web/angular/public/manifest.webmanifest.hbs @@ -0,0 +1,57 @@ +{ + "name": "{{projectName}}", + "short_name": "{{projectName}}", + "display": "standalone", + "scope": "./", + "start_url": "./", + "icons": [ + { + "src": "icons/icon-72x72.png", + "sizes": "72x72", + "type": "image/png", + "purpose": "maskable any" + }, + { + "src": "icons/icon-96x96.png", + "sizes": "96x96", + "type": "image/png", + "purpose": "maskable any" + }, + { + "src": "icons/icon-128x128.png", + "sizes": "128x128", + "type": "image/png", + "purpose": "maskable any" + }, + { + "src": "icons/icon-144x144.png", + "sizes": "144x144", + "type": "image/png", + "purpose": "maskable any" + }, + { + "src": "icons/icon-152x152.png", + "sizes": "152x152", + "type": "image/png", + "purpose": "maskable any" + }, + { + "src": "icons/icon-192x192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable any" + }, + { + "src": "icons/icon-384x384.png", + "sizes": "384x384", + "type": "image/png", + "purpose": "maskable any" + }, + { + "src": "icons/icon-512x512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable any" + } + ] +} diff --git a/apps/cli/templates/api/orpc/web/angular/src/services/rpc.service.ts.hbs b/apps/cli/templates/api/orpc/web/angular/src/services/rpc.service.ts.hbs new file mode 100644 index 00000000..e2b4bdaf --- /dev/null +++ b/apps/cli/templates/api/orpc/web/angular/src/services/rpc.service.ts.hbs @@ -0,0 +1,43 @@ +import { Injectable } from '@angular/core'; +import { createORPCClient } from "@orpc/client"; +import { RPCLink } from "@orpc/client/fetch"; +import { QueryCache, QueryClient } from '@tanstack/angular-query-experimental'; +import { toast } from 'ngx-sonner'; +import { appRouter, AppRouter } from '../../../server/src/routers'; +import { environment } from '../environments/environment'; +import type { RouterClient } from "@orpc/server"; +import { createTanstackQueryUtils } from '@orpc/tanstack-query'; + +export const queryClient = new QueryClient({ + queryCache: new QueryCache({ + onError: (error) => { + toast.error(error.message, { + action: { + label: "retry", + onClick: () => { + queryClient.invalidateQueries(); + }, + }, + }); + }, + }), +}); + +@Injectable({ + providedIn: 'root' +}) +export class RpcService { + private link = new RPCLink({ + url: `${environment.baseUrl}/rpc`, + fetch(url, options) { + return fetch(url, { + ...options, + credentials: "include", + }); + }, + }); + + private client: RouterClient = createORPCClient(this.link); + public utils = createTanstackQueryUtils(this.client); + +} diff --git a/apps/cli/templates/auth/web/angular/src/components/auth/login/login.component.html b/apps/cli/templates/auth/web/angular/src/components/auth/login/login.component.html new file mode 100644 index 00000000..c3b7d711 --- /dev/null +++ b/apps/cli/templates/auth/web/angular/src/components/auth/login/login.component.html @@ -0,0 +1,56 @@ +
+
+
+

Sign In

+

Welcome back! Please sign in to continue.

+ +
+ +
+ + + + @if (email.api.state.meta.isDirty && email.api.state.meta.errors) { + @for (error of email.api.state.meta.errors; track $index) { + {{ error.message }} + } + } +
+
+ + +
+ + + @if (password.api.state.meta.isDirty && password.api.state.meta.errors) { + @for (error of password.api.state.meta.errors; track $index) { + {{ error.message }} + } + } +
+
+ +
+

+ Don't have an account? + + Sign Up + +

+
+
+
diff --git a/apps/cli/templates/auth/web/angular/src/components/auth/login/login.component.ts b/apps/cli/templates/auth/web/angular/src/components/auth/login/login.component.ts new file mode 100644 index 00000000..29293eac --- /dev/null +++ b/apps/cli/templates/auth/web/angular/src/components/auth/login/login.component.ts @@ -0,0 +1,39 @@ +import { Component, inject } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { RouterLink } from '@angular/router'; +import { AuthService } from '../../../services/auth.service'; +import { injectForm, injectStore, TanStackField } from '@tanstack/angular-form'; +import { z } from 'zod'; + +@Component({ + selector: 'app-login', + standalone: true, + imports: [CommonModule, FormsModule, RouterLink, TanStackField], + templateUrl: './login.component.html', +}) +export class LoginComponent { + email = ''; + password = ''; + private authService = inject(AuthService); + loginSchema = z.object({ + email: z.string().email("Invalid email address"), + password: z.string().min(1, "Password is required"), + rememberMe: z.boolean(), + }); + logInForm = injectForm({ + defaultValues: { + email: "", + password: "", + rememberMe: false, + } as z.infer, + validators: { + onChange: this.loginSchema, + }, + onSubmit: async ({value}) => { + this.authService.login(value.email, value.password); + }, + }); + canSubmit = injectStore(this.logInForm, (state) => state.canSubmit); + isSubmitting = injectStore(this.logInForm, (state) => state.isSubmitting); +} diff --git a/apps/cli/templates/auth/web/angular/src/components/auth/signup/signup.component.html b/apps/cli/templates/auth/web/angular/src/components/auth/signup/signup.component.html new file mode 100644 index 00000000..52e1dd11 --- /dev/null +++ b/apps/cli/templates/auth/web/angular/src/components/auth/signup/signup.component.html @@ -0,0 +1,91 @@ +
+
+
+

Create Account

+

Join us! Create your account to get started.

+ +
+ +
+ + + @if (name.api.state.meta.isDirty && name.api.state.meta.errors) { + @for (error of name.api.state.meta.errors; track $index) { + {{ error.message }} + } + } +
+
+ +
+ + + @if (email.api.state.meta.isDirty && email.api.state.meta.errors) { + @for (error of email.api.state.meta.errors; track $index) { + {{ error.message }} + } + } +
+
+ +
+ + + @if (password.api.state.meta.isDirty && password.api.state.meta.errors) { + @for (error of password.api.state.meta.errors; track $index) { + {{ error.message }} + } + } +
+
+ +
+ +

+ Already have an account? + + Sign In + +

+
+
+
diff --git a/apps/cli/templates/auth/web/angular/src/components/auth/signup/signup.component.ts b/apps/cli/templates/auth/web/angular/src/components/auth/signup/signup.component.ts new file mode 100644 index 00000000..59b606ef --- /dev/null +++ b/apps/cli/templates/auth/web/angular/src/components/auth/signup/signup.component.ts @@ -0,0 +1,39 @@ +import { CommonModule } from '@angular/common'; +import { Component, inject } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { Router, RouterLink } from '@angular/router'; +import { injectForm, injectStore, TanStackField } from '@tanstack/angular-form'; +import { AuthService } from '../../../services/auth.service'; +import { z } from 'zod'; + +@Component({ + selector: 'app-signup', + standalone: true, + imports: [CommonModule, FormsModule, RouterLink, TanStackField], + templateUrl : './signup.component.html' +}) +export class SignupComponent { + #router = inject(Router); + private authService = inject(AuthService); + signUpSchema = 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(8, "Password must be at least 8 characters") + }); + signUpForm = injectForm({ + defaultValues: { + email: "", + password: "", + name: "", + } as z.infer, + validators: { + onChange: this.signUpSchema, + }, + onSubmit: async ({value}) => { + this.authService.signUp(value.email, value.password); + }, + }); + canSubmit = injectStore(this.signUpForm, (state) => state.canSubmit); + isSubmitting = injectStore(this.signUpForm, (state) => state.isSubmitting); +} diff --git a/apps/cli/templates/auth/web/angular/src/components/dashboard/dashboard.component.html b/apps/cli/templates/auth/web/angular/src/components/dashboard/dashboard.component.html new file mode 100644 index 00000000..4961c31a --- /dev/null +++ b/apps/cli/templates/auth/web/angular/src/components/dashboard/dashboard.component.html @@ -0,0 +1,12 @@ +
+
+
+
+
+

Dashboard

+

Welcome to your private dashboard

+
+
+
+
+
diff --git a/apps/cli/templates/auth/web/angular/src/components/dashboard/dashboard.component.ts b/apps/cli/templates/auth/web/angular/src/components/dashboard/dashboard.component.ts new file mode 100644 index 00000000..d6e7cc91 --- /dev/null +++ b/apps/cli/templates/auth/web/angular/src/components/dashboard/dashboard.component.ts @@ -0,0 +1,11 @@ +import { CommonModule } from '@angular/common'; +import { Component } from '@angular/core'; + +@Component({ + selector: 'app-dashboard', + standalone: true, + imports: [CommonModule], + templateUrl: './dashboard.component.html' +}) +export class DashboardComponent { +} diff --git a/apps/cli/templates/auth/web/angular/src/guards/auth.guard.ts b/apps/cli/templates/auth/web/angular/src/guards/auth.guard.ts new file mode 100644 index 00000000..aec1290c --- /dev/null +++ b/apps/cli/templates/auth/web/angular/src/guards/auth.guard.ts @@ -0,0 +1,15 @@ +import { inject } from '@angular/core'; +import { Router, type CanActivateFn } from '@angular/router'; +import { AuthService } from '../services/auth.service'; + +export const authGuard: CanActivateFn = (route, state) => { + const authService = inject(AuthService); + const router = inject(Router); + + if (authService.isLoggedIn()) { + return true; + } + + router.navigate(['/login']); + return false; +}; diff --git a/apps/cli/templates/auth/web/angular/src/services/auth.service.ts b/apps/cli/templates/auth/web/angular/src/services/auth.service.ts new file mode 100644 index 00000000..256f2f28 --- /dev/null +++ b/apps/cli/templates/auth/web/angular/src/services/auth.service.ts @@ -0,0 +1,83 @@ +import { inject, Injectable, OnInit, signal } from '@angular/core'; +import { Router } from '@angular/router'; +import { createAuthClient } from "better-auth/client"; +import type { User } from 'better-auth/types'; +import { toast } from 'ngx-sonner'; +import { BehaviorSubject } from 'rxjs'; +import { environment } from '../environments/environment'; +@Injectable({ + providedIn: 'root' +}) +export class AuthService implements OnInit { + private isAuthenticated = new BehaviorSubject(false); + isAuthenticated$ = this.isAuthenticated.asObservable(); + user = signal(null); + authClient = createAuthClient({ + baseURL: environment.baseUrl, + }); + router = inject(Router); + ngOnInit(): void { + this.getSession(); + } + login(email: string, password: string): void { + this.authClient.signIn.email({ email, password }, { + onSuccess: (session) => { + if(session.data?.user) { + this.isAuthenticated.next(true); + this.user.set(session.data?.user ?? null); + this.router.navigate(['/dashboard']); + } + }, + onError: (error) => { + console.error(error); + toast.error(error.error.message); + } + }); + } + signUp(email: string, password: string): void { + this.authClient.signUp.email({ email, password, name: email }, { + onSuccess: (session) => { + if(session.data?.user) { + this.isAuthenticated.next(true); + this.user.set(session.data?.user ?? null); + this.router.navigate(['/dashboard']); + } + }, + onError: (error) => { + console.error(error); + toast.error(error.error.message); + } + }); + } + + getSession(): void { + this.authClient.getSession({}, { + onSuccess: (session) => { + if(session.data?.user) { + this.isAuthenticated.next(true); + this.user.set(session.data?.user ?? null); + } + }, + onError: (error) => { + console.error(error); + toast.error(error.error.message); + } + }); + } + logout(): void { + this.authClient.signOut({}, { + onSuccess: () => { + this.isAuthenticated.next(false); + this.user.set(null); + this.router.navigate(['/login']); + }, + onError: (error) => { + console.error(error); + toast.error(error.error.message); + } + }); + } + isLoggedIn(): boolean { + return this.isAuthenticated.value; + } +} diff --git a/apps/cli/templates/examples/todo/web/angular/src/components/todo-list/todo-list.component.html b/apps/cli/templates/examples/todo/web/angular/src/components/todo-list/todo-list.component.html new file mode 100644 index 00000000..271664cc --- /dev/null +++ b/apps/cli/templates/examples/todo/web/angular/src/components/todo-list/todo-list.component.html @@ -0,0 +1,68 @@ +
+
+
+

Todo List

+

Manage your tasks efficiently

+ +
+
+ + +
+ + @if (todo.api.state.meta.isDirty && todo.api.state.meta.errors) { + @for (error of todo.api.state.meta.errors; track $index) { + {{ error.message }} + } + } +
+
+ +
+ @if (queryToDo.data()?.length) { + @for (todo of queryToDo.data(); track $index) { + @if(todo && todo.id) { +
+ + + {{ todo.text }} + + +
+ } + } + } @else { +
+

No tasks yet. Add your first task above!

+
+ } +
+
+
diff --git a/apps/cli/templates/examples/todo/web/angular/src/components/todo-list/todo-list.component.ts.hbs b/apps/cli/templates/examples/todo/web/angular/src/components/todo-list/todo-list.component.ts.hbs new file mode 100644 index 00000000..a27d4f10 --- /dev/null +++ b/apps/cli/templates/examples/todo/web/angular/src/components/todo-list/todo-list.component.ts.hbs @@ -0,0 +1,59 @@ +import { CommonModule } from '@angular/common'; +import { Component, inject } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { injectForm, injectStore, TanStackField } from '@tanstack/angular-form'; +import { injectMutation, injectQuery, QueryClient } from '@tanstack/angular-query-experimental'; +import { z } from 'zod'; +import { RpcService } from '../../services/rpc.service'; + +@Component({ + selector: 'app-todo-list', + standalone: true, + imports: [CommonModule, FormsModule, TanStackField], + templateUrl: './todo-list.component.html' +}) +export class TodoListComponent { + queryClient = inject(QueryClient); + private _rpc = inject(RpcService); + + queryToDo = injectQuery(() => this._rpc.utils.todo.getAll.queryOptions()) + + mutateToDo = injectMutation(() => this._rpc.utils.todo.create.mutationOptions({ + onSuccess: () => { + this.queryClient.invalidateQueries({ queryKey: this._rpc.utils.todo.getAll.key() }); + this.todoForm.reset(); + }, + })); + + updateToDo = injectMutation(() => this._rpc.utils.todo.toggle.mutationOptions({ + onSuccess: () => { + this.queryClient.invalidateQueries({ queryKey: this._rpc.utils.todo.getAll.key() }); + this.todoForm.reset(); + }, + })); + + deleteTodo = injectMutation(() => this._rpc.utils.todo.delete.mutationOptions({ + onSuccess: () => { + this.queryClient.invalidateQueries({ queryKey: this._rpc.utils.todo.getAll.key() }); + }, + })); + + todoSchema = z.object({ + todo: z.string().nonempty("Todo is required"), + }); + + todoForm = injectForm({ + defaultValues: { + todo: "", + } as z.infer, + validators: { + onChange: this.todoSchema, + }, + onSubmit: async ({ value }) => { + this.mutateToDo.mutate({ text: value.todo }); + }, + }); + canSubmit = injectStore(this.todoForm, (state) => state.canSubmit); + isSubmitting = injectStore(this.todoForm, (state) => state.isSubmitting); + +} diff --git a/apps/cli/templates/frontend/angular/.editorconfig b/apps/cli/templates/frontend/angular/.editorconfig new file mode 100644 index 00000000..f166060d --- /dev/null +++ b/apps/cli/templates/frontend/angular/.editorconfig @@ -0,0 +1,17 @@ +# Editor configuration, see https://editorconfig.org +root = true + +[*] +charset = utf-8 +indent_style = space +indent_size = 2 +insert_final_newline = true +trim_trailing_whitespace = true + +[*.ts] +quote_type = single +ij_typescript_use_double_quotes = false + +[*.md] +max_line_length = off +trim_trailing_whitespace = false diff --git a/apps/cli/templates/frontend/angular/.gitignore b/apps/cli/templates/frontend/angular/.gitignore new file mode 100644 index 00000000..cc7b1413 --- /dev/null +++ b/apps/cli/templates/frontend/angular/.gitignore @@ -0,0 +1,42 @@ +# See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files. + +# Compiled output +/dist +/tmp +/out-tsc +/bazel-out + +# Node +/node_modules +npm-debug.log +yarn-error.log + +# IDEs and editors +.idea/ +.project +.classpath +.c9/ +*.launch +.settings/ +*.sublime-workspace + +# Visual Studio Code +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +.history/* + +# Miscellaneous +/.angular/cache +.sass-cache/ +/connect.lock +/coverage +/libpeerconnection.log +testem.log +/typings + +# System files +.DS_Store +Thumbs.db diff --git a/apps/cli/templates/frontend/angular/.postcssrc.json b/apps/cli/templates/frontend/angular/.postcssrc.json new file mode 100644 index 00000000..85a17567 --- /dev/null +++ b/apps/cli/templates/frontend/angular/.postcssrc.json @@ -0,0 +1,5 @@ +{ + "plugins": { + "@tailwindcss/postcss": {} + } +} diff --git a/apps/cli/templates/frontend/angular/angular.json.hbs b/apps/cli/templates/frontend/angular/angular.json.hbs new file mode 100644 index 00000000..93aa81a0 --- /dev/null +++ b/apps/cli/templates/frontend/angular/angular.json.hbs @@ -0,0 +1,109 @@ +{ + "$schema": "./node_modules/@angular/cli/lib/config/schema.json", + "version": 1, + "newProjectRoot": "projects", + "projects": { + "my-app": { + "projectType": "application", + "schematics": {}, + "root": "", + "sourceRoot": "src", + "prefix": "app", + "architect": { + "build": { + "builder": "@angular-devkit/build-angular:application", + "options": { + "outputPath": "dist/my-app", + "index": "src/index.html", + "browser": "src/main.ts", + "polyfills": ["src/polyfills.ts"], + "tsConfig": "tsconfig.app.json", + "assets": [ + { + "glob": "**/*", + "input": "public" + } + ], + "styles": [ + "src/styles.css" + ], + "scripts": [], + "allowedCommonJsDependencies": [ + "@orpc/server", + "@orpc/client" + ], + "preserveSymlinks": true + }, + "configurations": { + "production": { + "fileReplacements": [ + { + "replace": "src/environments/environment.ts", + "with": "src/environments/environment.prod.ts" + } + ], + "optimization": true, + "sourceMap": true, + "namedChunks": false, + "extractLicenses": true, + "budgets": [ + { + "type": "initial", + "maximumWarning": "500kB", + "maximumError": "1MB" + }, + { + "type": "anyComponentStyle", + "maximumWarning": "4kB", + "maximumError": "8kB" + } + ], + "outputHashing": "all" + {{#if (includes addons "pwa")}} + "serviceWorker": "ngsw-config.json" + {{/if}} + }, + "development": { + "optimization": false, + "extractLicenses": false, + "sourceMap": true + } + }, + "defaultConfiguration": "production" + }, + "serve": { + "builder": "@angular-devkit/build-angular:dev-server", + "configurations": { + "production": { + "buildTarget": "my-app:build:production" + }, + "development": { + "buildTarget": "my-app:build:development" + } + }, + "defaultConfiguration": "development" + }, + "extract-i18n": { + "builder": "@angular-devkit/build-angular:extract-i18n" + }, + "test": { + "builder": "@angular-devkit/build-angular:karma", + "options": { + "polyfills": ["src/polyfills.ts"], + "tsConfig": "tsconfig.spec.json", + "assets": [ + { + "glob": "**/*", + "input": "public" + } + ], + "styles": [ + "src/styles.css" + ], + "scripts": [] + } + } + } + } + } +} diff --git a/apps/cli/templates/frontend/angular/package.json b/apps/cli/templates/frontend/angular/package.json new file mode 100644 index 00000000..8ff8cee2 --- /dev/null +++ b/apps/cli/templates/frontend/angular/package.json @@ -0,0 +1,44 @@ +{ + "name": "web", + "version": "0.0.0", + "scripts": { + "ng": "ng", + "dev": "ng serve --port=3001", + "start": "ng serve", + "build": "ng build", + "watch": "ng build --watch --configuration development", + "test": "ng test" + }, + "private": true, + "dependencies": { + "@angular/animations": "^20.0.0", + "@angular/common": "^20.0.0", + "@angular/compiler": "^20.0.0", + "@angular/core": "^20.0.0", + "@angular/forms": "^20.0.0", + "@angular/platform-browser": "^20.0.0", + "@angular/platform-browser-dynamic": "^20.0.0", + "@angular/router": "^20.0.0", + "@tailwindcss/postcss": "^4.1.8", + "@tanstack/angular-form": "^1.12.1", + "ngx-sonner": "^3.1.0", + "postcss": "^8.5.4", + "rxjs": "~7.8.2", + "tailwindcss": "^4.1.8", + "tslib": "^2.8.1", + "zone.js": "~0.15.1" + }, + "devDependencies": { + "@angular-devkit/build-angular": "^20.0.0", + "@angular/cli": "^20.0.0", + "@angular/compiler-cli": "^20.0.0", + "@types/jasmine": "~5.1.8", + "jasmine-core": "~5.6.0", + "karma": "~6.4.4", + "karma-chrome-launcher": "~3.2.0", + "karma-coverage": "~2.2.1", + "karma-jasmine": "~5.1.0", + "karma-jasmine-html-reporter": "~2.1.0", + "typescript": "~5.7.3" + } +} diff --git a/apps/cli/templates/frontend/angular/public/favicon.ico b/apps/cli/templates/frontend/angular/public/favicon.ico new file mode 100644 index 00000000..57614f9c Binary files /dev/null and b/apps/cli/templates/frontend/angular/public/favicon.ico differ diff --git a/apps/cli/templates/frontend/angular/src/app.component.html b/apps/cli/templates/frontend/angular/src/app.component.html new file mode 100644 index 00000000..e0af27e5 --- /dev/null +++ b/apps/cli/templates/frontend/angular/src/app.component.html @@ -0,0 +1,7 @@ +
+ +
+ +
+ +
diff --git a/apps/cli/templates/frontend/angular/src/app.component.ts b/apps/cli/templates/frontend/angular/src/app.component.ts new file mode 100644 index 00000000..193c4278 --- /dev/null +++ b/apps/cli/templates/frontend/angular/src/app.component.ts @@ -0,0 +1,24 @@ +import { CommonModule } from '@angular/common'; +import { Component, inject } from '@angular/core'; +import { RouterOutlet } from '@angular/router'; +import { NgxSonnerToaster } from 'ngx-sonner'; +import { HeaderComponent } from './components/header/header.component'; +import { ThemeService } from './services/theme.service'; + +@Component({ + selector: 'app-root', + standalone: true, + imports: [ + CommonModule, + RouterOutlet, + HeaderComponent, + NgxSonnerToaster + ], + templateUrl: './app.component.html', +}) +export class AppComponent { + private themeService = inject(ThemeService); + constructor() { + this.themeService.initTheme(); + } +} diff --git a/apps/cli/templates/frontend/angular/src/app.config.ts.hbs b/apps/cli/templates/frontend/angular/src/app.config.ts.hbs new file mode 100644 index 00000000..79e60d71 --- /dev/null +++ b/apps/cli/templates/frontend/angular/src/app.config.ts.hbs @@ -0,0 +1,52 @@ +import { + type ApplicationConfig, + provideZoneChangeDetection, +} from "@angular/core"; +import { provideAnimations } from "@angular/platform-browser/animations"; +import { provideRouter } from "@angular/router"; +import { routes } from "./app.routes"; +import { provideHttpClient, withInterceptors } from "@angular/common/http"; +import type { HttpHandlerFn, HttpInterceptorFn, HttpRequest } from "@angular/common/http"; +{{#if (not (eq api "none"))}} +import { queryClient } from "src/services/rpc.service"; +import { + QueryClient, + provideTanStackQuery, + withDevtools, +} from "@tanstack/angular-query-experimental"; +{{/if}} +{{#if (eq addons.pwa "true")}} +import { provideServiceWorker } from '@angular/service-worker'; +import { isDevMode } from '@angular/core'; +{{/if}} + + +const withCredentialsInterceptor: HttpInterceptorFn = ( + req: HttpRequest, + next: HttpHandlerFn, +) => { + const modifiedReq = req.clone({ + withCredentials: true, + }); + return next(modifiedReq); +}; +export const appConfig: ApplicationConfig = { + providers: [ + provideZoneChangeDetection({ eventCoalescing: true }), + provideRouter(routes), + provideAnimations(), + provideHttpClient(withInterceptors([withCredentialsInterceptor])), + {{#if (not (eq api "none"))}} + provideTanStackQuery( + queryClient, + withDevtools(() => ({ loadDevtools: "auto" })), + ), + {{/if}} + {{#if (eq addons.pwa "true")}} + provideServiceWorker('ngsw-worker.js', { + enabled: !isDevMode(), + registrationStrategy: 'registerWhenStable:30000', + }), + {{/if}} + ], +}; diff --git a/apps/cli/templates/frontend/angular/src/app.routes.ts.hbs b/apps/cli/templates/frontend/angular/src/app.routes.ts.hbs new file mode 100644 index 00000000..9f48e9ff --- /dev/null +++ b/apps/cli/templates/frontend/angular/src/app.routes.ts.hbs @@ -0,0 +1,25 @@ +import type { Route } from '@angular/router'; +import { HomeComponent } from './components/home/home.component'; +import { NotFoundComponent } from './components/not-found/not-found.component'; +{{#if (not (eq examples "none"))}} +import { TodoListComponent } from './components/todo-list/todo-list.component'; +{{/if}} +{{#if auth}} +import { LoginComponent } from './components/auth/login/login.component'; +import { DashboardComponent } from './components/dashboard/dashboard.component'; +import { SignupComponent } from './components/auth/signup/signup.component'; +import { authGuard } from './guards/auth.guard'; +{{/if}} + +export const routes: Route[] = [ + { path: '', component: HomeComponent }, + {{#if (not (eq examples "none"))}} + { path: 'todos', component: TodoListComponent }, + {{/if}} + {{#if auth}} + { path: 'login', component: LoginComponent }, + { path: 'signup', component: SignupComponent }, + { path: 'dashboard', component: DashboardComponent, canActivate: [authGuard] }, + {{/if}} + { path: '**', component: NotFoundComponent } +]; diff --git a/apps/cli/templates/frontend/angular/src/components/header/header.component.html.hbs b/apps/cli/templates/frontend/angular/src/components/header/header.component.html.hbs new file mode 100644 index 00000000..40fc44f0 --- /dev/null +++ b/apps/cli/templates/frontend/angular/src/components/header/header.component.html.hbs @@ -0,0 +1,117 @@ +
+
+ + +
+
+ + +
+ + + +
+
+ + {{#if auth}} + + + Sign In + + + +
+ + +
+
+
\{{ this.authService.user()?.name }}
+
\{{ this.authService.user()?.email }}
+
+
+ +
+
+
+ {{/if}} +
+
+
diff --git a/apps/cli/templates/frontend/angular/src/components/header/header.component.ts.hbs b/apps/cli/templates/frontend/angular/src/components/header/header.component.ts.hbs new file mode 100644 index 00000000..9ec93bd3 --- /dev/null +++ b/apps/cli/templates/frontend/angular/src/components/header/header.component.ts.hbs @@ -0,0 +1,48 @@ +import { Component, inject } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { RouterLink, RouterLinkActive } from '@angular/router'; +import { ThemeService } from '../../services/theme.service'; +{{#if auth}} +import { AuthService } from '../../services/auth.service'; +{{/if}} + +@Component({ + selector: 'app-header', + standalone: true, + imports: [CommonModule, RouterLink, RouterLinkActive], + templateUrl: './header.component.html' +}) +export class HeaderComponent { + + private themeService = inject(ThemeService); + darkMode$ = this.themeService.darkMode$; + showProfileMenu = false; + showThemeMenu = false; + {{#if auth}} + public authService = inject(AuthService); + isAuthenticated$ = this.authService.isAuthenticated$; + get userInitial(): string { + return this.authService.user()?.name?.charAt(0) ?? ''; + } + toggleProfileMenu(): void { + this.showProfileMenu = !this.showProfileMenu; + if (this.showProfileMenu) { + this.showThemeMenu = false; + } + } + logout(): void { + this.showProfileMenu = false; + this.authService.logout(); + } + {{/if}} + toggleThemeMenu(): void { + this.showThemeMenu = !this.showThemeMenu; + if (this.showThemeMenu) { + this.showProfileMenu = false; + } + } + setTheme(mode: 'light' | 'dark' | 'system'): void { + this.themeService.setTheme(mode); + this.showThemeMenu = false; + } +} diff --git a/apps/cli/templates/frontend/angular/src/components/home/home.component.html.hbs b/apps/cli/templates/frontend/angular/src/components/home/home.component.html.hbs new file mode 100644 index 00000000..35891699 --- /dev/null +++ b/apps/cli/templates/frontend/angular/src/components/home/home.component.html.hbs @@ -0,0 +1,30 @@ +
+
+
+██████╗ ███████╗████████╗████████╗███████╗██████╗
+██╔══██╗██╔════╝╚══██╔══╝╚══██╔══╝██╔════╝██╔══██╗
+██████╔╝█████╗     ██║      ██║   █████╗  ██████╔╝
+██╔══██╗██╔══╝     ██║      ██║   ██╔══╝  ██╔══██╗
+██████╔╝███████╗   ██║      ██║   ███████╗██║  ██║
+╚═════╝ ╚══════╝   ╚═╝      ╚═╝   ╚══════╝╚═╝  ╚═╝
+
+████████╗    ███████╗████████╗ █████╗  ██████╗██╗  ██╗
+╚══██╔══╝    ██╔════╝╚══██╔══╝██╔══██╗██╔════╝██║ ██╔╝
+   ██║       ███████╗   ██║   ███████║██║     █████╔╝
+   ██║       ╚════██║   ██║   ██╔══██║██║     ██╔═██╗
+   ██║       ███████║   ██║   ██║  ██║╚██████╗██║  ██╗
+   ╚═╝       ╚══════╝   ╚═╝   ╚═╝  ╚═╝ ╚═════╝╚═╝  ╚═╝
+
+ {{#if (eq api "orpc")}} +
+

API Status

+
+
+ + \{{query.isSuccess() ? 'Connected' : 'Disconnected'}} + +
+
+ {{/if}} +
+
diff --git a/apps/cli/templates/frontend/angular/src/components/home/home.component.ts.hbs b/apps/cli/templates/frontend/angular/src/components/home/home.component.ts.hbs new file mode 100644 index 00000000..c56684c7 --- /dev/null +++ b/apps/cli/templates/frontend/angular/src/components/home/home.component.ts.hbs @@ -0,0 +1,22 @@ +import { Component, inject, signal } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { RouterLink } from '@angular/router'; +import { injectQuery } from '@tanstack/angular-query-experimental'; +{{#if (eq api "orpc")}} +import { RpcService } from '../../services/rpc.service'; +{{/if}} + +@Component({ + selector: 'app-home', + standalone: true, + imports: [CommonModule], + templateUrl: './home.component.html', + styles: [] +}) +export class HomeComponent { + + {{#if (eq api "orpc")}} + private _rpc = inject(RpcService); + query = injectQuery(() => this._rpc.utils.healthCheck.queryOptions()); + {{/if}} +} diff --git a/apps/cli/templates/frontend/angular/src/components/not-found/not-found.component.html b/apps/cli/templates/frontend/angular/src/components/not-found/not-found.component.html new file mode 100644 index 00000000..0d8d09bd --- /dev/null +++ b/apps/cli/templates/frontend/angular/src/components/not-found/not-found.component.html @@ -0,0 +1,15 @@ +
+
+

404

+

Page Not Found

+

+ The page you're looking for doesn't exist or has been moved. +

+ + Return Home + +
+
diff --git a/apps/cli/templates/frontend/angular/src/components/not-found/not-found.component.ts b/apps/cli/templates/frontend/angular/src/components/not-found/not-found.component.ts new file mode 100644 index 00000000..d7687eb5 --- /dev/null +++ b/apps/cli/templates/frontend/angular/src/components/not-found/not-found.component.ts @@ -0,0 +1,11 @@ +import { Component } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { RouterLink } from '@angular/router'; + +@Component({ + selector: 'app-not-found', + standalone: true, + imports: [CommonModule, RouterLink], + templateUrl: './not-found.component.html' +}) +export class NotFoundComponent {} diff --git a/apps/cli/templates/frontend/angular/src/environments/environment.prod.ts b/apps/cli/templates/frontend/angular/src/environments/environment.prod.ts new file mode 100644 index 00000000..7a504353 --- /dev/null +++ b/apps/cli/templates/frontend/angular/src/environments/environment.prod.ts @@ -0,0 +1,5 @@ +export const environment = { + production: true, + baseUrl: "http://localhost:6100", + appName: "Auth Kit ", +}; diff --git a/apps/cli/templates/frontend/angular/src/environments/environment.ts b/apps/cli/templates/frontend/angular/src/environments/environment.ts new file mode 100644 index 00000000..e71c4146 --- /dev/null +++ b/apps/cli/templates/frontend/angular/src/environments/environment.ts @@ -0,0 +1,16 @@ +// This file can be replaced during build by using the `fileReplacements` array. +// `ng build --prod` replaces `environment.ts` with `environment.prod.ts`. +// The list of file replacements can be found in `angular.json`. + +export const environment = { + production: false, + baseUrl: "http://localhost:3000", +} as const; +/* + * For easier debugging in development mode, you can import the following file + * to ignore zone related error stack frames such as `zone.run`, `zoneDelegate.invokeTask`. + * + * This import should be commented out in production mode because it will have a negative impact + * on performance if an error is thrown. + */ +// import 'zone.js/plugins/zone-error'; // Included with Angular CLI. diff --git a/apps/cli/templates/frontend/angular/src/index.html.hbs b/apps/cli/templates/frontend/angular/src/index.html.hbs new file mode 100644 index 00000000..9e7e70ee --- /dev/null +++ b/apps/cli/templates/frontend/angular/src/index.html.hbs @@ -0,0 +1,19 @@ + + + + + MyApp + + + + {{#if (eq addons.pwa "true")}} + + {{/if}} + + + + {{#if (eq addons.pwa "true")}} + + {{/if}} + + diff --git a/apps/cli/templates/frontend/angular/src/main.ts b/apps/cli/templates/frontend/angular/src/main.ts new file mode 100644 index 00000000..1f6a1b05 --- /dev/null +++ b/apps/cli/templates/frontend/angular/src/main.ts @@ -0,0 +1,28 @@ +import { enableProdMode } from '@angular/core'; +import { bootstrapApplication } from '@angular/platform-browser'; +import { AppComponent } from './app.component'; +import { appConfig } from './app.config'; +import { environment } from './environments/environment'; + +if (environment.production) { + enableProdMode(); + //show this warning only on prod mode + if (window) { + selfXSSWarning(); + } +} +bootstrapApplication(AppComponent, appConfig).catch(err => console.error(err)); + +function selfXSSWarning() { + setTimeout(() => { + console.log( + "%c** STOP **", + "font-weight:bold; font: 2.5em Arial; color: white; background-color: #e11d48; padding-left: 15px; padding-right: 15px; border-radius: 25px; padding-top: 5px; padding-bottom: 5px;", + ); + console.log( + `\n + %cThis is a browser feature intended for developers. Using this console may allow attackers to impersonate you and steal your information sing an attack called Self-XSS. Do not enter or paste code that you do not understand.`, + "font-weight:bold; font: 2em Arial; color: #e11d48;", + ); + }); +} diff --git a/apps/cli/templates/frontend/angular/src/polyfills.ts b/apps/cli/templates/frontend/angular/src/polyfills.ts new file mode 100644 index 00000000..ccc20344 --- /dev/null +++ b/apps/cli/templates/frontend/angular/src/polyfills.ts @@ -0,0 +1,52 @@ +/** + * This file includes polyfills needed by Angular and is loaded before the app. + * You can add your own extra polyfills to this file. + * + * This file is divided into 2 sections: + * 1. Browser polyfills. These are applied before loading ZoneJS and are sorted by browsers. + * 2. Application imports. Files imported after ZoneJS that should be loaded before your main + * file. + * + * The current setup is for so-called "evergreen" browsers; the last versions of browsers that + * automatically update themselves. This includes Safari >= 10, Chrome >= 55 (including Opera), + * Edge >= 13 on the desktop, and iOS 10 and Chrome on mobile. + * + * Learn more in https://angular.io/guide/browser-support + */ + +/*************************************************************************************************** + * BROWSER POLYFILLS + */ + +/** + * By default, zone.js will patch all possible macroTask and DomEvents + * user can disable parts of macroTask/DomEvents patch by setting following flags + * because those flags need to be set before `zone.js` being loaded, and webpack + * will put import in the top of bundle, so user need to create a separate file + * in this directory (for example: zone-flags.ts), and put the following flags + * into that file, and then add the following code before importing zone.js. + * import './zone-flags'; + * + * The flags allowed in zone-flags.ts are listed here. + * + * The following flags will work for all browsers. + * + * (window as any).__Zone_disable_requestAnimationFrame = true; // disable patch requestAnimationFrame + * (window as any).__Zone_disable_on_property = true; // disable patch onProperty such as onclick + * (window as any).__zone_symbol__UNPATCHED_EVENTS = ['scroll', 'mousemove']; // disable patch specified eventNames + * + * in IE/Edge developer tools, the addEventListener will also be wrapped by zone.js + * with the following flag, it will bypass `zone.js` patch for IE/Edge + * + * (window as any).__Zone_enable_cross_context_check = true; + * + */ + +/*************************************************************************************************** + * Zone JS is required by default for Angular itself. + */ +import "zone.js"; // Included with Angular CLI. + +/*************************************************************************************************** + * APPLICATION IMPORTS + */ diff --git a/apps/cli/templates/frontend/angular/src/services/theme.service.ts b/apps/cli/templates/frontend/angular/src/services/theme.service.ts new file mode 100644 index 00000000..4bbb4b88 --- /dev/null +++ b/apps/cli/templates/frontend/angular/src/services/theme.service.ts @@ -0,0 +1,48 @@ +import { Injectable } from '@angular/core'; +import { BehaviorSubject } from 'rxjs'; + +@Injectable({ + providedIn: 'root' +}) +export class ThemeService { + private darkMode = new BehaviorSubject(false); + darkMode$ = this.darkMode.asObservable(); + private themeMode = new BehaviorSubject<'light' | 'dark' | 'system'>('system'); + themeMode$ = this.themeMode.asObservable(); + + constructor() { + // Listen for system theme changes + window.matchMedia('(prefers-color-scheme: dark)') + .addEventListener('change', e => { + if (this.themeMode.value === 'system') { + this.updateTheme(e.matches); + } + }); + } + + initTheme(): void { + const savedTheme = localStorage.getItem('themeMode') as 'light' | 'dark' | 'system' || 'system'; + this.setTheme(savedTheme); + } + + setTheme(mode: 'light' | 'dark' | 'system'): void { + this.themeMode.next(mode); + localStorage.setItem('themeMode', mode); + + if (mode === 'system') { + const systemDark = window.matchMedia('(prefers-color-scheme: dark)').matches; + this.updateTheme(systemDark); + } else { + this.updateTheme(mode === 'dark'); + } + } + + private updateTheme(isDark: boolean): void { + if (isDark) { + document.documentElement.classList.add('dark'); + } else { + document.documentElement.classList.remove('dark'); + } + this.darkMode.next(isDark); + } +} diff --git a/apps/cli/templates/frontend/angular/src/styles.css b/apps/cli/templates/frontend/angular/src/styles.css new file mode 100644 index 00000000..b760a742 --- /dev/null +++ b/apps/cli/templates/frontend/angular/src/styles.css @@ -0,0 +1,125 @@ +@import 'tailwindcss'; + +@custom-variant dark (&:is(.dark *)); + +@theme { + --color-primary-50: #f0f9ff; + --color-primary-100: #e0f2fe; + --color-primary-200: #bae6fd; + --color-primary-300: #7dd3fc; + --color-primary-400: #38bdf8; + --color-primary-500: #0ea5e9; + --color-primary-600: #0284c7; + --color-primary-700: #0369a1; + --color-primary-800: #075985; + --color-primary-900: #0c4a6e; + + --color-success-50: #f0fdf4; + --color-success-100: #dcfce7; + --color-success-500: #22c55e; + --color-success-700: #15803d; + + --color-warning-50: #fffbeb; + --color-warning-100: #fef3c7; + --color-warning-500: #f59e0b; + --color-warning-700: #b45309; + + --color-error-50: #fef2f2; + --color-error-100: #fee2e2; + --color-error-500: #ef4444; + --color-error-700: #b91c1c; + + --animate-fade-in: fadeIn 0.3s ease-in-out; + --animate-slide-in: slideIn 0.3s ease-out; + + @keyframes fadeIn { + 0% { + opacity: 0; + } + 100% { + opacity: 1; + } + } + @keyframes slideIn { + 0% { + transform: translateY(10px); + opacity: 0; + } + 100% { + transform: translateY(0); + opacity: 1; + } + } +} + +/* + The default border color has changed to `currentcolor` in Tailwind CSS v4, + so we've added these compatibility styles to make sure everything still + looks the same as it did with Tailwind CSS v3. + + If we ever want to remove these styles, we need to add an explicit border + color utility to any element that depends on these defaults. +*/ +@layer base { + *, + ::after, + ::before, + ::backdrop, + ::file-selector-button { + border-color: var(--color-gray-200, currentcolor); + } +} + +@utility btn { + @apply px-4 py-2 rounded-md font-medium transition-all duration-200; +} + +@utility btn-primary { + @apply bg-primary-600 text-white hover:bg-primary-700; +} + +@utility btn-secondary { + @apply bg-gray-200 text-gray-800 hover:bg-gray-300 dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600; +} + +@utility input { + @apply bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-md px-4 py-2 + focus:outline-hidden focus:ring-2 focus:ring-primary-500 dark:focus:ring-primary-600 + focus:border-transparent transition-all duration-200; +} + +@utility card { + @apply bg-white dark:bg-gray-800 rounded-lg shadow-md overflow-hidden transition-all duration-200; +} + +@utility todo-item { + @apply flex items-center gap-3 p-4 border-b border-gray-200 dark:border-gray-700 last:border-0 + transition-all duration-200 hover:bg-gray-50 dark:hover:bg-gray-700; +} + +@utility icon-btn { + @apply p-2 rounded-full hover:bg-gray-200 dark:hover:bg-gray-700 transition-all duration-200; +} + +@layer base { + body { + @apply bg-gray-100 text-gray-900 dark:bg-black dark:text-gray-100 min-h-screen; + font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + } + + h1, h2, h3, h4, h5, h6 { + @apply font-semibold leading-tight; + } + + h1 { + @apply text-2xl; + } + + h2 { + @apply text-xl; + } + + button { + @apply transition-all duration-200; + } +} diff --git a/apps/cli/templates/frontend/angular/src/types/env.d.ts b/apps/cli/templates/frontend/angular/src/types/env.d.ts new file mode 100644 index 00000000..ecf688da --- /dev/null +++ b/apps/cli/templates/frontend/angular/src/types/env.d.ts @@ -0,0 +1,11 @@ +declare namespace NodeJS { + interface ProcessEnv { + DATABASE_URL: string; + CORS_ORIGIN: string; + [key: string]: string | undefined; + } +} + +declare const process: { + env: NodeJS.ProcessEnv; +}; diff --git a/apps/cli/templates/frontend/angular/tsconfig.app.json b/apps/cli/templates/frontend/angular/tsconfig.app.json new file mode 100644 index 00000000..a6772523 --- /dev/null +++ b/apps/cli/templates/frontend/angular/tsconfig.app.json @@ -0,0 +1,30 @@ +/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ +/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "./out-tsc/app", + "paths": { + "@/*": [ + "./src/*" + ], + "@server/*": [ + "../server/*" + ] + } + }, + "files": [ + "src/main.ts", + "src/polyfills.ts" + ], + "include": [ + "src/**/*.d.ts", + "src/**/*.ts" + ], + "exclude": [ + "src/test.ts", + "src/**/*.spec.ts", + "src/**/*.test.ts", + "../server/**/*" + ] +} diff --git a/apps/cli/templates/frontend/angular/tsconfig.json b/apps/cli/templates/frontend/angular/tsconfig.json new file mode 100644 index 00000000..a4cd28ca --- /dev/null +++ b/apps/cli/templates/frontend/angular/tsconfig.json @@ -0,0 +1,43 @@ +/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ +/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ +{ + "compileOnSave": false, + "compilerOptions": { + "outDir": "./dist/out-tsc", + "strict": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "skipLibCheck": true, + "isolatedModules": true, + "esModuleInterop": true, + "experimentalDecorators": true, + "moduleResolution": "bundler", + "importHelpers": true, + "target": "ES2022", + "module": "ES2022", + "baseUrl": ".", + "preserveSymlinks": true, + "typeRoots": [ + "./node_modules/@types", + "./src/types" + ] + }, + "angularCompilerOptions": { + "enableI18nLegacyMessageIdFormat": false, + "strictInjectionParameters": true, + "strictInputAccessModifiers": true, + "strictTemplates": true + }, + "include": [ + "src/**/*.ts", + "src/**/*.d.ts" + ], + "exclude": [ + "../server/**/*", + "../node_modules/**/*", + "../dist/**/*", + "**/*.spec.ts" + ] +} diff --git a/apps/cli/templates/frontend/angular/tsconfig.spec.json b/apps/cli/templates/frontend/angular/tsconfig.spec.json new file mode 100644 index 00000000..6f9d5d71 --- /dev/null +++ b/apps/cli/templates/frontend/angular/tsconfig.spec.json @@ -0,0 +1,18 @@ +/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ +/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "./out-tsc/spec", + "types": [ + "jasmine" + ] + }, + "files": [ + "src/polyfills.ts" + ], + "include": [ + "src/**/*.spec.ts", + "src/**/*.d.ts" + ] +} diff --git a/apps/web/public/icon/angular.svg b/apps/web/public/icon/angular.svg new file mode 100644 index 00000000..4b62f5a3 --- /dev/null +++ b/apps/web/public/icon/angular.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/apps/web/src/app/(home)/_components/stack-builder.tsx b/apps/web/src/app/(home)/_components/stack-builder.tsx index 66c4b1e4..e84340c6 100644 --- a/apps/web/src/app/(home)/_components/stack-builder.tsx +++ b/apps/web/src/app/(home)/_components/stack-builder.tsx @@ -91,6 +91,7 @@ const hasWebFrontend = (webFrontend: string[]) => "nuxt", "svelte", "solid", + "angular", ].includes(f), ); @@ -100,7 +101,7 @@ const checkHasNativeFrontend = (nativeFrontend: string[]) => const hasPWACompatibleFrontend = (webFrontend: string[]) => webFrontend.some((f) => - ["tanstack-router", "react-router", "solid", "next"].includes(f), + ["tanstack-router", "react-router", "solid", "next", "angular"].includes(f), ); const hasTauriCompatibleFrontend = (webFrontend: string[]) => @@ -112,6 +113,7 @@ const hasTauriCompatibleFrontend = (webFrontend: string[]) => "svelte", "solid", "next", + "angular", ].includes(f), ); @@ -555,8 +557,18 @@ const analyzeStackCompatibility = (stack: StackState): CompatibilityResult => { const isNuxt = nextStack.webFrontend.includes("nuxt"); const isSvelte = nextStack.webFrontend.includes("svelte"); const isSolid = nextStack.webFrontend.includes("solid"); - if ((isNuxt || isSvelte || isSolid) && nextStack.api === "trpc") { - const frontendName = isNuxt ? "Nuxt" : isSvelte ? "Svelte" : "Solid"; + const isAngular = nextStack.webFrontend.includes("angular"); + if ( + (isNuxt || isSvelte || isSolid || isAngular) && + nextStack.api === "trpc" + ) { + const frontendName = isNuxt + ? "Nuxt" + : isSvelte + ? "Svelte" + : isSolid + ? "Solid" + : "Angular"; notes.api.notes.push( `${frontendName} requires oRPC. It will be selected automatically.`, ); @@ -652,6 +664,13 @@ const analyzeStackCompatibility = (stack: StackState): CompatibilityResult => { message: "AI example removed (not compatible with Solid)", }); } + if (isAngular && nextStack.examples.includes("ai")) { + incompatibleExamples.push("ai"); + changes.push({ + category: "examples", + message: "AI example removed (not compatible with Angular)", + }); + } const uniqueIncompatibleExamples = [...new Set(incompatibleExamples)]; if (uniqueIncompatibleExamples.length > 0) { @@ -691,6 +710,16 @@ const analyzeStackCompatibility = (stack: StackState): CompatibilityResult => { notes.webFrontend.hasIssue = true; notes.examples.hasIssue = true; } + if (isAngular && uniqueIncompatibleExamples.includes("ai")) { + notes.webFrontend.notes.push( + "AI example is not compatible with Angular. It will be removed.", + ); + notes.examples.notes.push( + "AI example is not compatible with Angular. It will be removed.", + ); + notes.webFrontend.hasIssue = true; + notes.examples.hasIssue = true; + } const originalExamplesLength = nextStack.examples.length; nextStack.examples = nextStack.examples.filter( @@ -717,7 +746,7 @@ const getCompatibilityRules = (stack: StackState) => { const hasSolid = stack.webFrontend.includes("solid"); const hasNuxt = stack.webFrontend.includes("nuxt"); const hasSvelte = stack.webFrontend.includes("svelte"); - + const hasAngular = stack.webFrontend.includes("angular"); return { isConvex, isBackendNone, @@ -725,10 +754,12 @@ const getCompatibilityRules = (stack: StackState) => { hasNativeFrontend, hasPWACompatible: hasPWACompatibleFrontend(stack.webFrontend), hasTauriCompatible: hasTauriCompatibleFrontend(stack.webFrontend), - hasNuxtOrSvelteOrSolid: hasNuxt || hasSvelte || hasSolid, + hasNuxtOrSvelteOrSolidOrAngular: + hasNuxt || hasSvelte || hasSolid || hasAngular, hasSolid, hasNuxt, hasSvelte, + hasAngular, }; }; @@ -1091,12 +1122,14 @@ const StackBuilder = () => { : "Disabled: No backend requires API to be 'None'.", ); } - if (techId === "trpc" && rules.hasNuxtOrSvelteOrSolid) { + if (techId === "trpc" && rules.hasNuxtOrSvelteOrSolidOrAngular) { const frontendName = rules.hasNuxt ? "Nuxt" : rules.hasSvelte ? "Svelte" - : "Solid"; + : rules.hasSolid + ? "Solid" + : "Angular"; addRule( category, techId, @@ -1303,6 +1336,13 @@ const StackBuilder = () => { "Disabled: The 'AI' example is not compatible with a Solid frontend.", ); } + if (rules.hasAngular && techId === "ai") { + addRule( + category, + techId, + "Disabled: The 'AI' example is not compatible with an Angular frontend.", + ); + } } } } diff --git a/apps/web/src/lib/constant.ts b/apps/web/src/lib/constant.ts index 9167c181..fc322c64 100644 --- a/apps/web/src/lib/constant.ts +++ b/apps/web/src/lib/constant.ts @@ -81,6 +81,15 @@ export const TECH_OPTIONS = { color: "from-blue-600 to-blue-800", default: false, }, + { + id: "angular", + name: "Angular", + description: + "The web framework that empowers developers to build fast, reliable applications", + icon: "/icon/angular.svg", + color: "from-red-500 to-red-700", + default: false, + }, { id: "none", name: "No Web Frontend",