From 97f2d1384e2db6f127f7272265e5e634c9c2288b Mon Sep 17 00:00:00 2001 From: Demis Bellot Date: Mon, 1 Dec 2025 18:46:37 +0800 Subject: [PATCH 01/38] Upgrade to .NET 10 deps --- MyApp.ServiceInterface/MyApp.ServiceInterface.csproj | 6 +++--- MyApp.ServiceModel/MyApp.ServiceModel.csproj | 2 +- MyApp.Tests/MyApp.Tests.csproj | 4 ++-- MyApp/MyApp.csproj | 12 ++++++------ README.md | 4 ++++ 5 files changed, 16 insertions(+), 12 deletions(-) diff --git a/MyApp.ServiceInterface/MyApp.ServiceInterface.csproj b/MyApp.ServiceInterface/MyApp.ServiceInterface.csproj index 1901f63..c713bca 100644 --- a/MyApp.ServiceInterface/MyApp.ServiceInterface.csproj +++ b/MyApp.ServiceInterface/MyApp.ServiceInterface.csproj @@ -14,9 +14,9 @@ - - - + + + \ No newline at end of file diff --git a/MyApp.ServiceModel/MyApp.ServiceModel.csproj b/MyApp.ServiceModel/MyApp.ServiceModel.csproj index 1e7da9b..bf127f0 100644 --- a/MyApp.ServiceModel/MyApp.ServiceModel.csproj +++ b/MyApp.ServiceModel/MyApp.ServiceModel.csproj @@ -7,7 +7,7 @@ - + \ No newline at end of file diff --git a/MyApp.Tests/MyApp.Tests.csproj b/MyApp.Tests/MyApp.Tests.csproj index 9a20f96..af241eb 100644 --- a/MyApp.Tests/MyApp.Tests.csproj +++ b/MyApp.Tests/MyApp.Tests.csproj @@ -18,8 +18,8 @@ - - + + diff --git a/MyApp/MyApp.csproj b/MyApp/MyApp.csproj index 6a6e61f..a05b0d5 100644 --- a/MyApp/MyApp.csproj +++ b/MyApp/MyApp.csproj @@ -28,12 +28,12 @@ - - - - - - + + + + + + diff --git a/README.md b/README.md index dc8bee0..d9d2fd6 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,10 @@ A modern full-stack .NET 10.0 + React Vite project template that combines the po npx create-net react-static MyProject ``` +## Jumpstart with Copilot + +Instantly [scaffold a new App with this template](https://github.com/new?template_name=react-static&template_owner=NetCoreTemplates) using GitHub Copilot, just describe the features you want and watch Copilot build it! + ## Getting Started Run Server .NET Project (automatically starts both .NET and Vite React dev servers): From a92e042bfb8e271a32e0be6b06e34118ed499605 Mon Sep 17 00:00:00 2001 From: Demis Bellot Date: Tue, 2 Dec 2025 19:12:06 +0800 Subject: [PATCH 02/38] Update MyApp.ServiceInterface.csproj --- MyApp.ServiceInterface/MyApp.ServiceInterface.csproj | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/MyApp.ServiceInterface/MyApp.ServiceInterface.csproj b/MyApp.ServiceInterface/MyApp.ServiceInterface.csproj index c713bca..e69f9fb 100644 --- a/MyApp.ServiceInterface/MyApp.ServiceInterface.csproj +++ b/MyApp.ServiceInterface/MyApp.ServiceInterface.csproj @@ -11,12 +11,12 @@ - + + \ No newline at end of file From bb03d0ddf7bb03b5bfbbcf65311d0608dff61e13 Mon Sep 17 00:00:00 2001 From: Demis Bellot Date: Tue, 2 Dec 2025 19:12:24 +0800 Subject: [PATCH 03/38] Add CLAUDE.md / AGENTS.md --- AGENTS.md | 593 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ CLAUDE.md | 1 + 2 files changed, 594 insertions(+) create mode 100644 AGENTS.md create mode 100644 CLAUDE.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..21ebbcf --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,593 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) and other Agents when working with code in this repository. + +## Project Overview + +A full-stack .NET 10 + React 19 + Vite template combining ServiceStack backend with React static site generation. Uses ASP.NET Core Identity for auth, OrmLite for application data, and Entity Framework Core for Identity data management. + +## Common Commands + +### Development + +```bash +# Start both .NET and Vite dev servers (from project root) +dotnet watch + +# After making changes to C# DTOs restart .NET before regenerating TypeScript DTOs by running: +cd MyApp.Client && npm run dtos +``` + +### Building + +```bash +# Build frontend (TypeScript + Vite) +cd MyApp.Client && npm run build + +# Build backend (.NET) +dotnet build + +# Build Tailwind CSS for Razor Pages +cd MyApp && npm run ui:build +``` + +### Testing + +```bash +# Frontend tests (Vitest) +cd MyApp.Client && npm run test # Watch mode +cd MyApp.Client && npm run test:ui # UI mode +cd MyApp.Client && npm run test:run # Single run + +# Backend tests (NUnit) +dotnet test +``` + +### Database Migrations +```bash +# Run all migrations (both EF Core and OrmLite) +cd MyApp && npm run migrate + +# Entity Framework migrations (for changes to Identity tables) +dotnet ef migrations add MigrationName +dotnet ef database update + +# Revert last migration +cd MyApp && npm run revert:last + +# Drop and re-run last migration (useful during development) +cd MyApp && npm run rerun:last +``` + +### AutoQuery CRUD Development (using okai) + +```bash +# Create new AutoQuery CRUD feature with TypeScript data model +npx okai init Table + +# Regenerate C# AutoQuery APIs and DB migration from .d.ts model +npx okai Table.d.ts + +# Remove AutoQuery feature and all generated code +npx okai rm Table.d.ts +``` + +## Architecture + +### Hybrid Development/Production Model + +**Development Mode:** +- `dotnet watch` from MyApp starts both .NET (port 5001) and Vite dev server (port 5173) +- ASP.NET Core proxies requests to Vite dev server via `NodeProxy` (configured in [Program.cs](MyApp/Program.cs#L41)) +- Hot Module Replacement (HMR) enabled via WebSocket proxying using `MapNotFoundToNode`, `MapViteHmr`, `RunNodeProcess`, `MapFallbackToNode` in [Program.cs](MyApp/Program.cs) + +**Production Mode:** +- Vite builds React app to `MyApp.Client/dist/`, which is copied to `MyApp/wwwroot/` when published +- ASP.NET Core serves static files directly from `wwwroot` - no Node.js required +- Fallback to `index.html` for client-side routing + +### Modular Startup Configuration + +AppHost uses .NET's `IHostingStartup` pattern to split configuration across multiple files in `MyApp/`: +- [Configure.AppHost.cs](MyApp/Configure.AppHost.cs) - Main ServiceStack AppHost registration +- [Configure.Auth.cs](MyApp/Configure.Auth.cs) - ServiceStack AuthFeature with ASP.NET Core Identity integration +- [Configure.AutoQuery.cs](MyApp/Configure.AutoQuery.cs) - AutoQuery features and audit events +- [Configure.Db.cs](MyApp/Configure.Db.cs) - Database setup (OrmLite for app data, EF Core for Identity) +- [Configure.Db.Migrations.cs](MyApp/Configure.Db.Migrations.cs) - Runs OrmLite and EF DB Migrations and creates initial users +- [Configure.BackgroundJobs.cs](MyApp/Configure.BackgroundJobs.cs) - Background job processing +- [Configure.HealthChecks.cs](MyApp/Configure.HealthChecks.cs) - Health monitoring endpoint + +This pattern keeps [Program.cs](MyApp/Program.cs) clean and separates concerns. Each `Configure.*.cs` file is auto-registered via `[assembly: HostingStartup]` attribute. + +### Project Structure + +``` +MyApp/ # .NET Backend (hosts both .NET and React) +├── Configure.*.cs # Modular AppHost configuration +├── Migrations/ # EF Core Identity migrations + OrmLite app migrations +├── Data/ # EF Core DbContext and Identity models +└── wwwroot/ # Production static files (from MyApp.Client/dist) + +MyApp.Client/ # React Frontend +├── src/ +│ ├── lib/ +│ │ ├── dtos.ts # Auto-generated from C# (via `npm run dtos`) +│ │ ├── gateway.ts # ServiceStack JsonServiceClient +│ │ └── utils.ts # Utility functions +│ ├── components/ # React components +│ └── styles/ # Tailwind CSS +└── vite.config.ts # API proxy config for dev mode + +MyApp.ServiceModel/ # DTOs & API contracts +├── *.cs # C# Request/Response DTOs +└── *.d.ts # TypeScript data models for okai code generation + +MyApp.ServiceInterface/ # Service implementations +└── *Services.cs # ServiceStack service classes + +MyApp.Tests/ # .NET tests (NUnit) +├── IntegrationTest.cs # API integration tests +└── MigrationTasks.cs # Migration task runner +``` + +### Database Architecture + +**Dual ORM Strategy:** +- **OrmLite**: All application data (faster, simpler, lighter typed ORM) +- **Entity Framework Core**: ASP.NET Core Identity tables only (Users, Roles, etc.) + +Both use the same SQLite database by default (`App_Data/app.db`). Connection string in `appsettings.json`. + +**Migration Files:** +- `MyApp/Migrations/20240301000000_CreateIdentitySchema.cs` - EF Core migration for Identity +- `MyApp/Migrations/Migration1000.cs` - OrmLite migration for app tables (e.g., Booking) + +Run `npm run migrate` to execute both. + +### Authentication Flow + +1. ASP.NET Core Identity handles user registration/login via Razor Pages at `/Identity/*` routes +2. ServiceStack AuthFeature integrates with Identity via `IdentityAuth.For()` in [Configure.Auth.cs](MyApp/Configure.Auth.cs) +3. Custom claims added via `AdditionalUserClaimsPrincipalFactory` and `CustomUserSession` +4. ServiceStack services use `[ValidateHasRole]` attributes for authorization (see [Bookings.cs](MyApp.ServiceModel/Bookings.cs)) + +### ServiceStack .NET APIs + +ServiceStack APIs adopt a [DTOs-first approach utilizing message-based APIs](https://docs.servicestack.net/api-design). To create ServiceStack APIs create all related DTOs used in the API (aka Service Contracts) into a single file in the `MyApp.ServiceModel` project, e.g: + +```csharp +//MyApp.ServiceModel/Bookings.cs + +public class GetBooking : IGet, IReturn +{ + [ValidateGreaterThan(0)] + public int Id { get; set; } +} +public class GetBookingResponse +{ + public Booking? Result { get; set; } + public ResponseStatus? ResponseStatus { get; set; } +} +``` + +The response type of an API should be specified in the `IReturn` marker interface. APIs which don't have any response should implement `IReturnVoid` instead. + +By convention APIs returning a single result have a `T? Result` property whilst APIs returning multiple results of the same type have a `List Results` property. Otherwise it's encouraged to use intuitive property names for APIs returning results of different types in a flat structured Response DTO for simplicity. + +These Server DTOs are what gets generated in `dtos.ts`. + +#### Validating APIs + +Any API Errors are automatically populated in the `ResponseStatus` property including when using [Declarative Validation Attributes](https://docs.servicestack.net/declarative-validation) like `[ValidateGreaterThan]` and `[ValidateNotEmpty]` which validate APIs and returned any structured error responses in `ResponseStatus`. + +#### Protecting APIs + +The Type Validation Attributes below should be used to protect APIs: + +- `[ValidateIsAuthenticated]` - Authenticated Users only +- `[ValidateIsAdmin]` - Only Admin Users +- `[ValidateHasRole]` - Only Authenticated Users assigned with the specified role +- `[ValidateApiKey]` - Only Users with a valid API Key + +```csharp +//MyApp.ServiceModel/Bookings.cs +[ValidateHasRole("Employee")] +public class CreateBooking : ICreateDb, IReturn +{ + //... +} +``` + +#### Primary HTTP Method + +APIs have a primary HTTP Method which if not specified uses HTTP **POST**. Use `IGet`, `IPost`, `IPut` or `IDelete` to change the HTTP Verb except for AutoQuery APIs which have implied verbs for each operation. + +#### API Implementations + +Implementations of ServiceStack APIs should be added to the `MyApp.ServiceInterface` folder, e.g: + +```csharp +//MyApp.ServiceInterface/BookingServices.cs +public class BookingServices(IAutoQueryDb autoquery) : Service +{ + public object Any(GetBooking request) + { + return new GetBookingResponse { + Result = base.Db.SingleById(request.Id) + ?? throw HttpError.NotFound("Booking does not exist") + }; + } + + // Example of overriding an AutoQuery API with a custom implementation + public async Task Any(QueryBookings request) + { + using var db = autoQuery.GetDb(request, base.Request); + var q = autoQuery.CreateQuery(request, base.Request, db); + return await autoQuery.ExecuteAsync(request, q, base.Request, db); + } +} +``` + +APIs can be implemented with **sync** or **async** methods. The return type of an API implementation does not change behavior however it's encouraged to use `object` to encourage + +The ServiceStack `Service` base class has convenience properties like `Db` to resolve an Open `IDbConnection` for that API and `base.Request` to resolve the `IRequest` context. All other dependencies required by the API should use a Primary Constructor with constructor injection. + +A ServiceStack API typically returns the Response DTO defined in its Request DTO `IReturn` or an Error but can also return any [custom Return Type](https://docs.servicestack.net/service-return-types) like a raw `string`, `byte[]`, `Stream`, `IStreamWriter`, `HttpResult` and `HttpError`. + +### AutoQuery CRUD Pattern + +ServiceStack's AutoQuery generates full CRUD APIs from declarative request DTOs. Example in [Bookings.cs](MyApp.ServiceModel/Bookings.cs): + +- `QueryBookings : QueryDb` → GET /api/QueryBookings with filtering/sorting/paging +- `CreateBooking : ICreateDb` → POST /api/CreateBooking +- `UpdateBooking : IPatchDb` → PATCH /api/UpdateBooking +- `DeleteBooking : IDeleteDb` → DELETE /api/DeleteBooking + +**No service implementation required** - AutoQuery handles it. Audit fields (`CreatedBy`, `ModifiedBy`, etc.) auto-populated via `[AutoApply(Behavior.AuditCreate)]` attributes. + +[AutoQuery CRUD Docs](https://react-templates.net/docs/autoquery/crud) + +### TypeScript DTO Generation + +After changing C# DTOs in `MyApp.ServiceModel/`, run: +```bash +cd MyApp.Client && npm run dtos +``` + +This calls ServiceStack's `/types/typescript` endpoint and updates `MyApp.Client/src/lib/dtos.ts` with type-safe client DTOs. The Vite dev server auto-reloads. + +### okai AutoQuery Code Generation + +The `npx okai` tool generates C# AutoQuery APIs and migrations from TypeScript data models (`.d.ts` files): + +1. **TypeScript data model** (`MyApp.ServiceModel/Bookings.d.ts`) defines the entity with decorators +2. **C# AutoQuery APIs** (`MyApp.ServiceModel/Bookings.cs`) - auto-generated CRUD request/response DTOs +3. **C# OrmLite migration** (`MyApp/Migrations/Migration1000.cs`) - auto-generated schema creation + +This enables rapid prototyping: edit the `.d.ts` model, run `npx okai Bookings.d.ts`, then `npm run migrate`. + +**Important:** The `.d.ts` files use special decorators (e.g., `@validateHasRole`, `@autoIncrement`) that map to C# attributes and .NET Types. The valid schema for these is defined in [api.d.ts](MyApp.ServiceModel/api.d.ts). Reference [Bookings.d.ts](MyApp.ServiceModel/Bookings.d.ts) for examples. + +### AutoQuery APIs + +[C# AutoQuery APIs](https://react-templates.net/docs/autoquery/querying) allow creating queryable C# APIs for RDBMS Tables with just a Request DTO definition, e.g: + +```csharp +public class QueryBookings : QueryDb +{ + public int? Id { get; set; } + public decimal? MinCost { get; set; } + public List? CostBetween { get; set; } + public List? Ids { get; set; } +} +``` + +It uses these conventions to determine the behavior of each property filter: + +```csharp +ImplicitConventions = new() { + {"%Above%", "{Field} > {Value}"}, + {"Begin%", "{Field} > {Value}"}, + {"%Beyond%", "{Field} > {Value}"}, + {"%Over%", "{Field} > {Value}"}, + {"%OlderThan", "{Field} > {Value}"}, + {"%After%", "{Field} > {Value}"}, + {"OnOrAfter%", "{Field} >= {Value}"}, + {"%From%", "{Field} >= {Value}"}, + {"Since%", "{Field} >= {Value}"}, + {"Start%", "{Field} >= {Value}"}, + {"%Higher%", "{Field} >= {Value}"}, + {"Min%", "{Field} >= {Value}"}, + {"Minimum%", "{Field} >= {Value}"}, + {"Behind%", "{Field} < {Value}"}, + {"%Below%", "{Field} < {Value}"}, + {"%Under%", "{Field} < {Value}"}, + {"%Lower%", "{Field} < {Value}"}, + {"%Before%", "{Field} < {Value}"}, + {"%YoungerThan", "{Field} < {Value}"}, + {"OnOrBefore%", "{Field} < {Value}"}, + {"End%", "{Field} < {Value}"}, + {"Stop%", "{Field} < {Value}"}, + {"To%", "{Field} < {Value}"}, + {"Until%", "{Field} < {Value}"}, + {"Max%", "{Field} < {Value}"}, + {"Maximum%", "{Field} < {Value}"}, + + {"%GreaterThanOrEqualTo%", "{Field} >= {Value}"}, + {"%GreaterThan%", "{Field} > {Value}"}, + {"%LessThan%", "{Field} < {Value}"}, + {"%LessThanOrEqualTo%", "{Field} < {Value}"}, + {"%NotEqualTo", "{Field} <> {Value}"}, + + {"Like%", "UPPER({Field}) LIKE UPPER({Value})"}, + {"%In", "{Field} IN ({Values})"}, + {"%Ids", "{Field} IN ({Values})"}, + {"%Between%", "{Field} BETWEEN {Value1} AND {Value2}"}, + {"%HasAll", "{Value} & {Field} = {Value}"}, + {"%HasAny", "{Value} & {Field} > 0"}, + + {"%IsNull", "{Field} IS NULL"}, + {"%IsNotNull", "{Field} IS NOT NULL"}, +}; +``` + +Each convention key includes `%` wildcards to define where a DataModel field names can appear, either as a Prefix, Suffix or both. The convention value describes the SQL filter that gets applied to the query when the property is populated. + +Properties that matches a DataModel field performs an exact query `{Field} = {Value}`, e.g: + +```typescript +const api = client.api(new QueryBookings({ id:1 })) +``` + +As `MinCost` matches the `"Min%"` convention it applies the `Cost >= 100` filter to the query: + +```typescript +const api = client.api(new QueryBookings({ minCost:100 })) +``` + +As `CostBetween` matches the `"%Between%"` convention it applies the `Cost BETWEEN 100 AND 200` filter to the query: + +```typescript +const api = client.api(new QueryBookings({ costBetween:[100,200] })) +``` + +AutoQuery also matches on pluralized fields where `Ids` matches `Id` and applies the `Id IN (1,2,3)` filter: + +```typescript +const api = client.api(new QueryBookings({ ids:[1,2,3] })) +``` + +Multiple Request DTO properties applies mutliple **AND** Filters, e.g: + +```typescript +const api = client.api(new QueryBookings({ minCost:100, ids:[1,2,3] })) +``` + +Applies the `(Cost >= 100) AND (Id IN (1,2,3))` filter. + +## Key Conventions + +### API Client Usage + +Frontend code imports from `lib/gateway.ts`: + +```typescript +import { client } from '@/lib/gateway' +import { QueryBookings } from '@/lib/dtos' + +const response = await client.api(new QueryBookings()) +``` + +The `client` is a configured `JsonServiceClient` pointing to `/api` (proxied to .NET backend). + +All .NET APIs are accessible by Request DTOs which implement a `IReturn` or a `IReturnVoid` interface which defines the Response of an API, e.g: + +```typescript +export class Hello implements IReturn, IGet +{ + public name: string; + public constructor(init?: Partial) { (Object as any).assign(this, init); } +} +export class HelloResponse +{ + public result: string; + public constructor(init?: Partial) { (Object as any).assign(this, init); } +} +``` + +### ServiceStack API Client Pattern + +Inside a React Component use `useClient()` to resolve a Service Client. The `ApiResult` can be used to hold **loading**, **failed** and **successful** API Response states, e.g: + +```typescript +type Props = { value: string } +export default ({ value }:Props) => { + const [name, setName] = useState(value) + const client = useClient() + const [api, setApi] = useState>(new ApiResult()) + + useEffect(() => { + (async () => { + setApi(new ApiResult()) + setApi(await client.api(new Hello({ name }))) + })() + }, [name]) + + return (
+ + {api.error + ?
{api.error.message}
+ : api.succeeded + ?
{api.response.result}
+ :
loading...
} +
) +} +``` + +All client `api`, `apiVoid` and `apiForm` methods **never throws exceptions** - it always returns an `ApiResult` which contains either a **response** for successful responses or an **error** with a populated `ResponseStatus`, as such using `try/catch` around `client.api*` calls is always wrong as it implies it would throw an Exception, when it never does. + +The examples below show typical usage: + +The `api` and `apiVoid` APIs return an `ApiResult` which holds both successful and failed API Responses: + +```typescript +const api = await client.api(new Hello({ name })) +if (api.succeeded) { + console.log(`The API succeded:`, api.response) +} else if (api.error) { + console.log(`The API failed:`, api.error) +} +``` + +The `apiForm` API can use a HTML Form's FormData for its Request Body together with the APIs **empty Request DTO**, e.g: + +```typescript +const submit = async (e: React.FormEvent) => { + const form = e.currentTarget as HTMLFormElement + const api = await client.apiForm(new CreateContact(), new FormData(form)) + if (api.succeeded) { + console.log(`The API succeded:`, api.response) + } else if (api.error) { + console.log(`The API failed:`, api.error) + } +} +``` + +Using `apiForm` is required for multipart/form-data File Uploads. + +### ServiceStack Form Components + +The `@servicestack/react` [component library](https://react.servicestack.net) have several components to simplify UI generation. + +The `` Component can be used to render an API validation bound form for **any Request DTO**. + +```jsx +import { AutoForm, AutoCreateForm, AutoEditForm, HtmlFormat } from '@servicestack/react' + +function GenericFormExample() { + const [results, setResults] = useState() + + const onSuccess = (response:QueryResponse) => { + setResults(response.results) + } + + return ( + + {results && } + ) +} +``` + +The `` can be used with an AutoQuery CRUD `ICreateDb` DTO to render a create entity form. + +```jsx + +``` + +The `` can be used with an AutoQuery CRUD `IPatchDb` or `IUpdateDb` DTO to render an update form. +The `deleteType` can be set to use an `IDeleteDb` DTO to enable delete functionality. +```jsx +function EditFormExample({ booking }:{ Booking:booking }) { + return ( + ) +} +``` + +### ApiStateContext and Input Components + +The `ApiStateContext` can be used to inject an APIs Error `ResponseStatus` down to all `@servicestack/react` Input components. + +```jsx +import {ErrorSummary, TextInput, PrimaryButton, useClient, ApiStateContext} from "@servicestack/react" + +function CustomFormExample() { + const client = useClient() + const [userName, setUserName] = useState() + const [password, setPassword] = useState() + + const onSubmit = async (e:SyntheticEvent) => { + e.preventDefault() + const api = await client.api(new Authenticate({ provider: 'credentials', userName, password })) + if (api.succeeded) { + console.log('Signed In!', api.response) + } else if (api.error.errorCode === 'Unauthorized') { + console.log('Sign In failed:', api.error.message) + } + } + + return ( +
+ +
+ + + Log in +
+ +
) +} +``` + +All field errors are displayed next to their Input component all other API errors are displayed with the `` component. Use `except` to avoid displaying field errors which are already displayed next to their associated input component. + +If needed, the error ResponseStatus can be passed to components using its `status` property. + +### Routing + +- `/api/*` → ServiceStack services +- `/Identity/*` → ASP.NET Core Identity Razor Pages +- `/ui/*` → ServiceStack API Explorer +- `/admin-ui/*` → ServiceStack Admin UI (requires Admin role) +- `/types/typescript` → ServiceStack .NET API TypeScript DTOs (for dtos.ts) +- All other routes → React SPA (via fallback in dev/prod) + +### Razor Pages Integration + +The template includes Razor Pages for Identity UI (`/Identity` routes) that coexist with the React SPA. These use Tailwind CSS compiled from `MyApp/tailwind.input.css` to `MyApp/wwwroot/css/app.css`. + +### Environment Variables + +- `KAMAL_DEPLOY_HOST` - Production hostname for deployment + +### Background Jobs + +Configured in [Configure.BackgroundJobs.cs](MyApp/Configure.BackgroundJobs.cs) using `BackgroundsJobFeature`. Jobs are commands that implement `IAsyncCommand`. + +## Development Workflow + +1. **Start dev servers:** `dotnet watch` (starts both .NET and Vite) +2. **Make backend changes:** Edit C# files in `MyApp.ServiceModel` or `MyApp.ServiceInterface` +3. **Restart .NET Server** +4. **Regenerate DTOs:** `cd MyApp.Client && npm run dtos` +5. **Make frontend changes:** Edit React files in `MyApp.Client/src` +6. **Add new CRUD feature:** + - `npx okai init Feature` + - Edit `MyApp.ServiceModel/Feature.d.ts` + - `npx okai Feature.d.ts` + - `npm run migrate` + +Docs: [AutoQuery Dev Worfklow](https://react-templates.net/docs/autoquery/dev-workflow) + +## Admin Features + +- `/admin-ui` - ServiceStack Admin UI (database, users, API explorer) +- `/admin-ui/users` - User management (requires Admin role) +- `/up` - Health check endpoint + +## Deployment + +GitHub Actions workflows in `.github/workflows/` uses [Kamal for Deployments](https://react-templates.net/docs/deployments): +- `build.yml` - CI build and test +- `build-container.yml` - Docker image build +- `release.yml` - Kamal deployment to production + +Configure `KAMAL_DEPLOY_HOST` in GitHub secrets for your hostname. Kamal config in `config/deploy.yml` derives service names from repository name. \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..eef4bd2 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md \ No newline at end of file From 053c5614f7d99d4d0a70a3bf38b00e71771b7d40 Mon Sep 17 00:00:00 2001 From: Demis Bellot Date: Tue, 2 Dec 2025 20:29:00 +0800 Subject: [PATCH 04/38] Update AGENTS.md --- AGENTS.md | 59 +++++++++++++++++++++++++++++-------------------------- 1 file changed, 31 insertions(+), 28 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 21ebbcf..f1e7f61 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -102,13 +102,13 @@ This pattern keeps [Program.cs](MyApp/Program.cs) clean and separates concerns. ### Project Structure ``` -MyApp/ # .NET Backend (hosts both .NET and React) -├── Configure.*.cs # Modular AppHost configuration -├── Migrations/ # EF Core Identity migrations + OrmLite app migrations -├── Data/ # EF Core DbContext and Identity models -└── wwwroot/ # Production static files (from MyApp.Client/dist) +MyApp/ # .NET Backend (hosts both .NET and React) +├── Configure.*.cs # Modular AppHost configuration +├── Migrations/ # EF Core Identity migrations + OrmLite app migrations +├── Pages/ # Identity Auth Razor Pages +└── wwwroot/ # Production static files (from MyApp.Client/dist) -MyApp.Client/ # React Frontend +MyApp.Client/ # React Frontend ├── src/ │ ├── lib/ │ │ ├── dtos.ts # Auto-generated from C# (via `npm run dtos`) @@ -116,14 +116,16 @@ MyApp.Client/ # React Frontend │ │ └── utils.ts # Utility functions │ ├── components/ # React components │ └── styles/ # Tailwind CSS -└── vite.config.ts # API proxy config for dev mode +└── vite.config.ts # Vite config for dev mode MyApp.ServiceModel/ # DTOs & API contracts ├── *.cs # C# Request/Response DTOs +├── api.d.ts # TypeScript data models Schema └── *.d.ts # TypeScript data models for okai code generation MyApp.ServiceInterface/ # Service implementations -└── *Services.cs # ServiceStack service classes +├── Data/ # EF Core DbContext and Identity models +└── *Services.cs # ServiceStack service implementations MyApp.Tests/ # .NET tests (NUnit) ├── IntegrationTest.cs # API integration tests @@ -133,7 +135,7 @@ MyApp.Tests/ # .NET tests (NUnit) ### Database Architecture **Dual ORM Strategy:** -- **OrmLite**: All application data (faster, simpler, lighter typed ORM) +- **OrmLite**: All application data (faster, simpler, typed POCO ORM) - **Entity Framework Core**: ASP.NET Core Identity tables only (Users, Roles, etc.) Both use the same SQLite database by default (`App_Data/app.db`). Connection string in `appsettings.json`. @@ -149,7 +151,7 @@ Run `npm run migrate` to execute both. 1. ASP.NET Core Identity handles user registration/login via Razor Pages at `/Identity/*` routes 2. ServiceStack AuthFeature integrates with Identity via `IdentityAuth.For()` in [Configure.Auth.cs](MyApp/Configure.Auth.cs) 3. Custom claims added via `AdditionalUserClaimsPrincipalFactory` and `CustomUserSession` -4. ServiceStack services use `[ValidateHasRole]` attributes for authorization (see [Bookings.cs](MyApp.ServiceModel/Bookings.cs)) +4. ServiceStack services use `[ValidateIsAuthenticated]` and `[ValidateHasRole]` attributes for authorization (see [Bookings.cs](MyApp.ServiceModel/Bookings.cs)) ### ServiceStack .NET APIs @@ -170,21 +172,21 @@ public class GetBookingResponse } ``` -The response type of an API should be specified in the `IReturn` marker interface. APIs which don't have any response should implement `IReturnVoid` instead. +The response type of an API should be specified in the `IReturn` marker interface. APIs which don't return a response should implement `IReturnVoid` instead. -By convention APIs returning a single result have a `T? Result` property whilst APIs returning multiple results of the same type have a `List Results` property. Otherwise it's encouraged to use intuitive property names for APIs returning results of different types in a flat structured Response DTO for simplicity. +By convention, APIs return single results in a `T? Result` property, APIs returns multiple results of the same type in a `List Results` property. Otherwise APIs returning results of different types should use intuitive property names in a flat structured Response DTO for simplicity. -These Server DTOs are what gets generated in `dtos.ts`. +These C# Server DTOs are used to generate TypeScript `dtos.ts`. #### Validating APIs -Any API Errors are automatically populated in the `ResponseStatus` property including when using [Declarative Validation Attributes](https://docs.servicestack.net/declarative-validation) like `[ValidateGreaterThan]` and `[ValidateNotEmpty]` which validate APIs and returned any structured error responses in `ResponseStatus`. +Any API Errors are automatically populated in the `ResponseStatus` property, inc. [Declarative Validation Attributes](https://docs.servicestack.net/declarative-validation) like `[ValidateGreaterThan]` and `[ValidateNotEmpty]` which validate APIs and return any error responses in `ResponseStatus`. #### Protecting APIs The Type Validation Attributes below should be used to protect APIs: -- `[ValidateIsAuthenticated]` - Authenticated Users only +- `[ValidateIsAuthenticated]` - Only Authenticated Users - `[ValidateIsAdmin]` - Only Admin Users - `[ValidateHasRole]` - Only Authenticated Users assigned with the specified role - `[ValidateApiKey]` - Only Users with a valid API Key @@ -200,11 +202,11 @@ public class CreateBooking : ICreateDb, IReturn #### Primary HTTP Method -APIs have a primary HTTP Method which if not specified uses HTTP **POST**. Use `IGet`, `IPost`, `IPut` or `IDelete` to change the HTTP Verb except for AutoQuery APIs which have implied verbs for each operation. +APIs have a primary HTTP Method which if not specified uses HTTP **POST**. Use `IGet`, `IPost`, `IPut`, `IPatch` or `IDelete` to change the HTTP Verb except for AutoQuery APIs which have implied verbs for each CRUD operation. #### API Implementations -Implementations of ServiceStack APIs should be added to the `MyApp.ServiceInterface` folder, e.g: +ServiceStack API implementations should be added to `MyApp.ServiceInterface/`: ```csharp //MyApp.ServiceInterface/BookingServices.cs @@ -228,11 +230,12 @@ public class BookingServices(IAutoQueryDb autoquery) : Service } ``` -APIs can be implemented with **sync** or **async** methods. The return type of an API implementation does not change behavior however it's encouraged to use `object` to encourage +APIs can be implemented with **sync** or **async** methods using `Any` or its primary HTTP Method e.g. `Get`, `Post`. +The return type of an API implementation does not change behavior however returning `object` is recommended so its clear the Request DTO `IReturn` interface defines the APIs Response type and Service Contract. -The ServiceStack `Service` base class has convenience properties like `Db` to resolve an Open `IDbConnection` for that API and `base.Request` to resolve the `IRequest` context. All other dependencies required by the API should use a Primary Constructor with constructor injection. +The ServiceStack `Service` base class has convenience properties like `Db` to resolve an Open `IDbConnection` for that API and `base.Request` to resolve the `IRequest` context. All other dependencies required by the API should use constructor injection in a Primary Constructor. -A ServiceStack API typically returns the Response DTO defined in its Request DTO `IReturn` or an Error but can also return any [custom Return Type](https://docs.servicestack.net/service-return-types) like a raw `string`, `byte[]`, `Stream`, `IStreamWriter`, `HttpResult` and `HttpError`. +A ServiceStack API typically returns the Response DTO defined in its Request DTO `IReturn` or an Error but can also return any raw [custom Return Type](https://docs.servicestack.net/service-return-types) like `string`, `byte[]`, `Stream`, `IStreamWriter`, `HttpResult` and `HttpError`. ### AutoQuery CRUD Pattern @@ -249,12 +252,12 @@ ServiceStack's AutoQuery generates full CRUD APIs from declarative request DTOs. ### TypeScript DTO Generation -After changing C# DTOs in `MyApp.ServiceModel/`, run: +After changing C# DTOs in `MyApp.ServiceModel/`, restart the .NET Server then run: ```bash cd MyApp.Client && npm run dtos ``` -This calls ServiceStack's `/types/typescript` endpoint and updates `MyApp.Client/src/lib/dtos.ts` with type-safe client DTOs. The Vite dev server auto-reloads. +This calls ServiceStack's `/types/typescript` endpoint and updates `dtos.ts` with type-safe client DTOs. The Vite dev server auto-reloads. ### okai AutoQuery Code Generation @@ -357,7 +360,7 @@ AutoQuery also matches on pluralized fields where `Ids` matches `Id` and applies const api = client.api(new QueryBookings({ ids:[1,2,3] })) ``` -Multiple Request DTO properties applies mutliple **AND** Filters, e.g: +Multiple Request DTO properties applies mutliple **AND** filters, e.g: ```typescript const api = client.api(new QueryBookings({ minCost:100, ids:[1,2,3] })) @@ -380,7 +383,7 @@ const response = await client.api(new QueryBookings()) The `client` is a configured `JsonServiceClient` pointing to `/api` (proxied to .NET backend). -All .NET APIs are accessible by Request DTOs which implement a `IReturn` or a `IReturnVoid` interface which defines the Response of an API, e.g: +All .NET APIs are accessible by Request DTOs which implement either a `IReturn` a `IReturnVoid` interface which defines the API Response, e.g: ```typescript export class Hello implements IReturn, IGet @@ -397,7 +400,7 @@ export class HelloResponse ### ServiceStack API Client Pattern -Inside a React Component use `useClient()` to resolve a Service Client. The `ApiResult` can be used to hold **loading**, **failed** and **successful** API Response states, e.g: +Inside a React Component use `useClient()` to resolve a Service Client. The `ApiResult` can hold **loading**, **failed** and **successful** API Response states, e.g: ```typescript type Props = { value: string } @@ -439,12 +442,12 @@ if (api.succeeded) { } ``` -The `apiForm` API can use a HTML Form's FormData for its Request Body together with the APIs **empty Request DTO**, e.g: +The `apiForm` API can use a HTML Form's FormData for its Request Body together with an APIs **empty Request DTO**, e.g: ```typescript const submit = async (e: React.FormEvent) => { - const form = e.currentTarget as HTMLFormElement - const api = await client.apiForm(new CreateContact(), new FormData(form)) + const form = e.currentTarget as HTMLFormElement + const api = await client.apiForm(new CreateContact(), new FormData(form)) if (api.succeeded) { console.log(`The API succeded:`, api.response) } else if (api.error) { From 44e412b1c340ce84f3da908f8cce822a9044657d Mon Sep 17 00:00:00 2001 From: Demis Bellot Date: Tue, 2 Dec 2025 21:13:24 +0800 Subject: [PATCH 05/38] Update AGENTS.md --- AGENTS.md | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index f1e7f61..43b8756 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -77,7 +77,7 @@ npx okai rm Table.d.ts ### Hybrid Development/Production Model **Development Mode:** -- `dotnet watch` from MyApp starts both .NET (port 5001) and Vite dev server (port 5173) +- `dotnet watch` from MyApp starts .NET (port 5001) and Vite dev server (port 5173), accesible via `https://localhost:5001` - ASP.NET Core proxies requests to Vite dev server via `NodeProxy` (configured in [Program.cs](MyApp/Program.cs#L41)) - Hot Module Replacement (HMR) enabled via WebSocket proxying using `MapNotFoundToNode`, `MapViteHmr`, `RunNodeProcess`, `MapFallbackToNode` in [Program.cs](MyApp/Program.cs) @@ -102,8 +102,8 @@ This pattern keeps [Program.cs](MyApp/Program.cs) clean and separates concerns. ### Project Structure ``` -MyApp/ # .NET Backend (hosts both .NET and React) -├── Configure.*.cs # Modular AppHost configuration +MyApp/ # .NET Backend (hosts both .NET and Vite React) +├── Configure.*.cs # Modular startup configuration ├── Migrations/ # EF Core Identity migrations + OrmLite app migrations ├── Pages/ # Identity Auth Razor Pages └── wwwroot/ # Production static files (from MyApp.Client/dist) @@ -130,6 +130,14 @@ MyApp.ServiceInterface/ # Service implementations MyApp.Tests/ # .NET tests (NUnit) ├── IntegrationTest.cs # API integration tests └── MigrationTasks.cs # Migration task runner + +config/ +└── deploy.yml # Kamal deployment settings +.github/ +└── workflows/ + ├── build.yml # CI build and test + ├── build-container.yml # Container image build + └── release.yml # Production deployment with Kamal ``` ### Database Architecture @@ -176,7 +184,7 @@ The response type of an API should be specified in the `IReturn` marke By convention, APIs return single results in a `T? Result` property, APIs returns multiple results of the same type in a `List Results` property. Otherwise APIs returning results of different types should use intuitive property names in a flat structured Response DTO for simplicity. -These C# Server DTOs are used to generate TypeScript `dtos.ts`. +These C# Server DTOs are used to generate the TypeScript `dtos.ts`. #### Validating APIs From 4f6547bf862eb6e42a636fbf620d2889ccb1f5f8 Mon Sep 17 00:00:00 2001 From: Demis Bellot Date: Tue, 2 Dec 2025 22:22:27 +0800 Subject: [PATCH 06/38] Delete NuGet.Config --- NuGet.Config | 7 ------- 1 file changed, 7 deletions(-) delete mode 100644 NuGet.Config diff --git a/NuGet.Config b/NuGet.Config deleted file mode 100644 index ee59bee..0000000 --- a/NuGet.Config +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - \ No newline at end of file From 3e2796cc3c74fa49af02ca5acadce0d2591a877a Mon Sep 17 00:00:00 2001 From: Demis Bellot Date: Tue, 2 Dec 2025 22:40:19 +0800 Subject: [PATCH 07/38] Update build.yml --- .github/workflows/build.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index d40e0e0..bf26f5d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -18,8 +18,8 @@ jobs: with: dotnet-version: 10.0.x - - name: Restore NuGet packages (use repo NuGet.config) - run: dotnet restore MyApp.slnx --configfile ./NuGet.Config + - name: Restore NuGet packages + run: dotnet restore MyApp.slnx # If your feed requires authentication, enable and configure the step below. # This example uses a Personal Access Token stored in secrets.NUGET_API_KEY. From 7cd6f7aafc6082ee2b69e1238ec4df168d425269 Mon Sep 17 00:00:00 2001 From: Demis Bellot Date: Wed, 3 Dec 2025 10:40:15 +0800 Subject: [PATCH 08/38] Update Program.cs --- MyApp/Program.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/MyApp/Program.cs b/MyApp/Program.cs index 93f5dd5..380795d 100644 --- a/MyApp/Program.cs +++ b/MyApp/Program.cs @@ -76,6 +76,7 @@ app.MapViteHmr(nodeProxy); // Start the Vite dev server if the lockfile does not exist + "../MyApp.Client/dist".AssertDir(); app.RunNodeProcess(nodeProxy, lockFile: "../MyApp.Client/dist/lock", workingDirectory: "../MyApp.Client"); From 4e843b2871b128305ab2ad318fff9fc51230b869 Mon Sep 17 00:00:00 2001 From: Demis Bellot Date: Wed, 3 Dec 2025 11:51:47 +0800 Subject: [PATCH 09/38] Update README.md --- README.md | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index d9d2fd6..7b2d65c 100644 --- a/README.md +++ b/README.md @@ -378,7 +378,11 @@ npx okai rm Transaction.d.ts ## Learn More +- [react-templates.net](https://react-templates.net) +- [ServiceStack React Components](https://react.servicestack.net) - [ServiceStack Documentation](https://docs.servicestack.net) -- [Next.js Documentation](https://nextjs.org/docs) -- [AutoQuery CRUD](https://docs.servicestack.net/autoquery-crud) -- [ServiceStack Auth](https://docs.servicestack.net/authentication-and-authorization) \ No newline at end of file +- [Vite Documentation](https://vite.dev) +- [React Documentation](https://react.dev) +- [AutoQuery CRUD](https://react-templates.net/docs/autoquery/crud) +- [Background Jobs](https://docs.servicestack.net/background-jobs) +- [AI Chat API](https://docs.servicestack.net/ai-chat-api) From 843c0ff02f72434bc8607e3ae7804f8361f7d440 Mon Sep 17 00:00:00 2001 From: Demis Bellot Date: Wed, 3 Dec 2025 12:42:14 +0800 Subject: [PATCH 10/38] Listen on all interfaces (both IPv4 and IPv6) --- MyApp.Client/vite.config.ts | 1 + MyApp/Program.cs | 1 - 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/MyApp.Client/vite.config.ts b/MyApp.Client/vite.config.ts index 0c64258..5376914 100644 --- a/MyApp.Client/vite.config.ts +++ b/MyApp.Client/vite.config.ts @@ -40,6 +40,7 @@ export default defineConfig({ target: 'baseline-widely-available', }, server: { + host: true, // Listen on all interfaces (both IPv4 and IPv6) proxy, } }) diff --git a/MyApp/Program.cs b/MyApp/Program.cs index 380795d..93f5dd5 100644 --- a/MyApp/Program.cs +++ b/MyApp/Program.cs @@ -76,7 +76,6 @@ app.MapViteHmr(nodeProxy); // Start the Vite dev server if the lockfile does not exist - "../MyApp.Client/dist".AssertDir(); app.RunNodeProcess(nodeProxy, lockFile: "../MyApp.Client/dist/lock", workingDirectory: "../MyApp.Client"); From 0325172939b2b0f93086b86038ba29854b39a5a2 Mon Sep 17 00:00:00 2001 From: Demis Bellot Date: Wed, 3 Dec 2025 13:09:04 +0800 Subject: [PATCH 11/38] updates --- MyApp.Client/vite.config.ts | 16 ++++++++-------- MyApp/Program.cs | 7 +++---- MyApp/Properties/launchSettings.json | 5 ++--- 3 files changed, 13 insertions(+), 15 deletions(-) diff --git a/MyApp.Client/vite.config.ts b/MyApp.Client/vite.config.ts index 5376914..4287d23 100644 --- a/MyApp.Client/vite.config.ts +++ b/MyApp.Client/vite.config.ts @@ -10,8 +10,8 @@ const isProd = process.env.NODE_ENV === 'production' const buildLocal = process.env.MODE === 'local' // Define DEPLOY_API first -const DEPLOY_API = process.env.KAMAL_DEPLOY_HOST - ? `https://${process.env.KAMAL_DEPLOY_HOST}` +const DEPLOY_API = process.env.KAMAL_DEPLOY_HOST + ? `https://${process.env.KAMAL_DEPLOY_HOST}` : target // Now use it for API_URL @@ -19,11 +19,11 @@ const API_URL = isProd ? DEPLOY_API : (buildLocal ? '' : target) // Only required if accessing vite directly, e.g. http://localhost:5173 const proxy = { - '^/api': { - target, - secure: false - } + '^/api': { + target, + secure: false } +} export default defineConfig({ define: { apiBaseUrl: `"${API_URL}"` }, @@ -41,6 +41,6 @@ export default defineConfig({ }, server: { host: true, // Listen on all interfaces (both IPv4 and IPv6) - proxy, - } + proxy, + } }) diff --git a/MyApp/Program.cs b/MyApp/Program.cs index 93f5dd5..e68d41f 100644 --- a/MyApp/Program.cs +++ b/MyApp/Program.cs @@ -38,7 +38,7 @@ services.AddServiceStack(typeof(MyServices).Assembly); var app = builder.Build(); -var nodeProxy = new NodeProxy("http://localhost:5173") { +var nodeProxy = new NodeProxy("http://127.0.0.1:5173") { Log = app.Logger }; @@ -72,14 +72,13 @@ // Proxy development HMR WebSocket and fallback routes to the Node server if (app.Environment.IsDevelopment()) { - app.UseWebSockets(); - app.MapViteHmr(nodeProxy); - // Start the Vite dev server if the lockfile does not exist app.RunNodeProcess(nodeProxy, lockFile: "../MyApp.Client/dist/lock", workingDirectory: "../MyApp.Client"); + app.UseWebSockets(); + app.MapViteHmr(nodeProxy); app.MapFallbackToNode(nodeProxy); } else diff --git a/MyApp/Properties/launchSettings.json b/MyApp/Properties/launchSettings.json index df59ab1..4e5d547 100644 --- a/MyApp/Properties/launchSettings.json +++ b/MyApp/Properties/launchSettings.json @@ -22,6 +22,7 @@ "commandName": "Project", "dotnetRunMessages": true, "launchBrowser": true, + "launchUrl": "", "applicationUrl": "https://localhost:5001;http://localhost:5000", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" @@ -30,11 +31,9 @@ "IIS Express": { "commandName": "IISExpress", "launchBrowser": true, - "launchUrl": "metadata", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } } } -} - +} \ No newline at end of file From 4c9175d76917d5c9ee72b13a2e61188804b9854d Mon Sep 17 00:00:00 2001 From: Demis Bellot Date: Wed, 3 Dec 2025 15:06:32 +0800 Subject: [PATCH 12/38] Update package.json --- MyApp.Client/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/MyApp.Client/package.json b/MyApp.Client/package.json index 6a03303..524c0be 100644 --- a/MyApp.Client/package.json +++ b/MyApp.Client/package.json @@ -12,7 +12,7 @@ "postinstall": "node ./postinstall.mjs" }, "dependencies": { - "@servicestack/react": "^2.0.15", + "@servicestack/react": "^2.0.17", "@tailwindcss/forms": "^0.5.10", "clsx": "^2.1.1", "react": "^19.2.0", From fd343d5c841ed44003d7bdc360a51c53df4f4014 Mon Sep 17 00:00:00 2001 From: Demis Bellot Date: Wed, 3 Dec 2025 17:21:08 +0800 Subject: [PATCH 13/38] Add NuGet.Config --- NuGet.Config | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 NuGet.Config diff --git a/NuGet.Config b/NuGet.Config new file mode 100644 index 0000000..ee59bee --- /dev/null +++ b/NuGet.Config @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file From 748188f58e8f54d23369e20991aac160c857e8ed Mon Sep 17 00:00:00 2001 From: Demis Bellot Date: Wed, 3 Dec 2025 18:14:50 +0800 Subject: [PATCH 14/38] Update docs link --- MyApp/Pages/Shared/Footer.cshtml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/MyApp/Pages/Shared/Footer.cshtml b/MyApp/Pages/Shared/Footer.cshtml index 121fb59..01efab6 100644 --- a/MyApp/Pages/Shared/Footer.cshtml +++ b/MyApp/Pages/Shared/Footer.cshtml @@ -6,7 +6,7 @@
Read Documentation From d91746c99108ab0f407b944b2fee7e5749e25b73 Mon Sep 17 00:00:00 2001 From: Demis Bellot Date: Wed, 3 Dec 2025 18:19:52 +0800 Subject: [PATCH 15/38] fix footer links --- MyApp/Pages/Shared/Footer.cshtml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/MyApp/Pages/Shared/Footer.cshtml b/MyApp/Pages/Shared/Footer.cshtml index 01efab6..a57550f 100644 --- a/MyApp/Pages/Shared/Footer.cshtml +++ b/MyApp/Pages/Shared/Footer.cshtml @@ -12,7 +12,7 @@ Read Documentation View on GitHub From 367f928550cb198f149a0a83d31625fb5e3882c7 Mon Sep 17 00:00:00 2001 From: Demis Bellot Date: Wed, 3 Dec 2025 19:35:40 +0800 Subject: [PATCH 16/38] Update README.md --- README.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 7b2d65c..b707736 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,11 @@ # react-static -React + Vite + TypeScript + Tailwind CSS project template with ServiceStack .NET backend. +A modern full-stack .NET 10.0 + React Vite project template that combines the power of ServiceStack with React Vite static site generation and React 19. It provides a production-ready foundation for building scalable web applications with integrated authentication, database management, and background job processing. ![](https://github.com/ServiceStack/docs.servicestack.net/blob/main/MyApp/wwwroot/img/pages/react/react-static.webp) > Browse [source code](https://github.com/NetCoreTemplates/react-static), view live demo [react-static.web-templates.io](http://react-static.web-templates.io) and install with: -A modern full-stack .NET 10.0 + React Vite project template that combines the power of ServiceStack with React Vite static site generation and React 19. It provides a production-ready foundation for building scalable web applications with integrated authentication, database management, and background job processing. - -## Quick Start - ```bash npx create-net react-static MyProject ``` @@ -18,6 +14,10 @@ npx create-net react-static MyProject Instantly [scaffold a new App with this template](https://github.com/new?template_name=react-static&template_owner=NetCoreTemplates) using GitHub Copilot, just describe the features you want and watch Copilot build it! +## [react-templates.net](https://react-templates.net) + +[![](https://github.com/ServiceStack/servicestack.net/blob/main/MyApp/wwwroot/img/posts/vibecode-react-templates/bg.webp?raw=true)](https://react-templates.net) + ## Getting Started Run Server .NET Project (automatically starts both .NET and Vite React dev servers): From 55200b05708bf0435e5d6670d1445155561ad76c Mon Sep 17 00:00:00 2001 From: Demis Bellot Date: Wed, 3 Dec 2025 19:37:09 +0800 Subject: [PATCH 17/38] Update README.md --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index b707736..1498eb2 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,8 @@ A modern full-stack .NET 10.0 + React Vite project template that combines the po > Browse [source code](https://github.com/NetCoreTemplates/react-static), view live demo [react-static.web-templates.io](http://react-static.web-templates.io) and install with: +## Quick Start + ```bash npx create-net react-static MyProject ``` From e9075b75d55699fbd401082c489368ed403c6b8c Mon Sep 17 00:00:00 2001 From: Demis Bellot Date: Wed, 3 Dec 2025 21:12:58 +0800 Subject: [PATCH 18/38] update img link --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 1498eb2..2252e5c 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ Instantly [scaffold a new App with this template](https://github.com/new?templat ## [react-templates.net](https://react-templates.net) -[![](https://github.com/ServiceStack/servicestack.net/blob/main/MyApp/wwwroot/img/posts/vibecode-react-templates/bg.webp?raw=true)](https://react-templates.net) +[![](https://github.com/ServiceStack/docs.servicestack.net/blob/main/MyApp/wwwroot/img/pages/react/react-templates.webp?raw=true)](https://react-templates.net) ## Getting Started From 9e1d153c4105a18ce45e64b9839ba887597524ce Mon Sep 17 00:00:00 2001 From: Demis Bellot Date: Wed, 3 Dec 2025 23:33:01 +0800 Subject: [PATCH 19/38] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 2252e5c..1498eb2 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ Instantly [scaffold a new App with this template](https://github.com/new?templat ## [react-templates.net](https://react-templates.net) -[![](https://github.com/ServiceStack/docs.servicestack.net/blob/main/MyApp/wwwroot/img/pages/react/react-templates.webp?raw=true)](https://react-templates.net) +[![](https://github.com/ServiceStack/servicestack.net/blob/main/MyApp/wwwroot/img/posts/vibecode-react-templates/bg.webp?raw=true)](https://react-templates.net) ## Getting Started From 0aaf2006b1de4cf089166b479818dd31903fd2d4 Mon Sep 17 00:00:00 2001 From: Demis Bellot Date: Thu, 4 Dec 2025 00:51:05 +0800 Subject: [PATCH 20/38] Upgrade npm deps --- MyApp.Client/package.json | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/MyApp.Client/package.json b/MyApp.Client/package.json index 524c0be..617a403 100644 --- a/MyApp.Client/package.json +++ b/MyApp.Client/package.json @@ -15,20 +15,20 @@ "@servicestack/react": "^2.0.17", "@tailwindcss/forms": "^0.5.10", "clsx": "^2.1.1", - "react": "^19.2.0", - "react-dom": "^19.2.0", + "react": "^19.2.1", + "react-dom": "^19.2.1", "react-net-templates": "^0.0.1", - "react-router-dom": "^7.9.5", + "react-router-dom": "^7.10.0", "tailwind-merge": "^3.4.0" }, "devDependencies": { "@eslint/js": "^9.39.1", "@tailwindcss/postcss": "^4.1.17", "@tailwindcss/vite": "^4.1.17", - "@types/node": "^24.10.0", - "@types/react": "^19.2.2", - "@types/react-dom": "^19.2.2", - "@vitejs/plugin-react": "^5.1.0", + "@types/node": "^24.10.1", + "@types/react": "^19.2.7", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^5.1.1", "eslint": "^9.39.1", "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.4.24", @@ -36,7 +36,7 @@ "postcss": "^8.5.6", "tailwindcss": "^4.1.17", "typescript": "~5.9.3", - "typescript-eslint": "^8.46.4", - "vite": "^7.2.2" + "typescript-eslint": "^8.48.1", + "vite": "^7.2.6" } } From 1949463f2e746394ac6ca7427eb6bca1d1b9a5cd Mon Sep 17 00:00:00 2001 From: Demis Bellot Date: Thu, 4 Dec 2025 22:54:11 +0800 Subject: [PATCH 21/38] delete NuGet.Config --- NuGet.Config | 7 ------- 1 file changed, 7 deletions(-) delete mode 100644 NuGet.Config diff --git a/NuGet.Config b/NuGet.Config deleted file mode 100644 index ee59bee..0000000 --- a/NuGet.Config +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - \ No newline at end of file From b9489efccd1c578703a4509dd26dfde655776d08 Mon Sep 17 00:00:00 2001 From: Demis Bellot Date: Fri, 5 Dec 2025 13:04:58 +0800 Subject: [PATCH 22/38] switch to new RunNodeProcess --- MyApp/Program.cs | 13 ++++--------- NuGet.Config | 7 +++++++ 2 files changed, 11 insertions(+), 9 deletions(-) create mode 100644 NuGet.Config diff --git a/MyApp/Program.cs b/MyApp/Program.cs index e68d41f..a7e566e 100644 --- a/MyApp/Program.cs +++ b/MyApp/Program.cs @@ -69,22 +69,17 @@ options.MapEndpoints(); }); -// Proxy development HMR WebSocket and fallback routes to the Node server +// Proxy HMR WebSocket and fallback routes to Node dev server in Development if (app.Environment.IsDevelopment()) { - // Start the Vite dev server if the lockfile does not exist - app.RunNodeProcess(nodeProxy, - lockFile: "../MyApp.Client/dist/lock", - workingDirectory: "../MyApp.Client"); - + app.RunNodeProcess(nodeProxy, "../MyApp.Client"); // Start Node if not running app.UseWebSockets(); app.MapViteHmr(nodeProxy); - app.MapFallbackToNode(nodeProxy); + app.MapFallbackToNode(nodeProxy); // Fallback to Node dev server in development } else { - // Map fallback to index.html in production (MyApp.Client/dist > wwwroot) - app.MapFallbackToFile("index.html"); + app.MapFallbackToFile("index.html"); // Fallback to index.html in production (MyApp.Client/dist > wwwroot) } app.Run(); diff --git a/NuGet.Config b/NuGet.Config new file mode 100644 index 0000000..c00d271 --- /dev/null +++ b/NuGet.Config @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file From 108ec3df0e53e3238f204e224007fa2421d3fe43 Mon Sep 17 00:00:00 2001 From: Demis Bellot Date: Fri, 5 Dec 2025 14:50:31 +0800 Subject: [PATCH 23/38] delete NuGet.Config --- NuGet.Config | 7 ------- 1 file changed, 7 deletions(-) delete mode 100644 NuGet.Config diff --git a/NuGet.Config b/NuGet.Config deleted file mode 100644 index c00d271..0000000 --- a/NuGet.Config +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - \ No newline at end of file From 3bef9efae33a74b2209d98ed7001ed054eda0b04 Mon Sep 17 00:00:00 2001 From: Demis Bellot Date: Sat, 6 Dec 2025 13:56:46 +0800 Subject: [PATCH 24/38] Update vite.config.ts --- MyApp.Client/vite.config.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/MyApp.Client/vite.config.ts b/MyApp.Client/vite.config.ts index 4287d23..33ead04 100644 --- a/MyApp.Client/vite.config.ts +++ b/MyApp.Client/vite.config.ts @@ -41,6 +41,7 @@ export default defineConfig({ }, server: { host: true, // Listen on all interfaces (both IPv4 and IPv6) + open: false, proxy, } }) From b0e80de234b337f02a2fa13f8128eb4353a49799 Mon Sep 17 00:00:00 2001 From: Demis Bellot Date: Sat, 6 Dec 2025 14:51:10 +0800 Subject: [PATCH 25/38] Update vite.config.ts --- MyApp.Client/vite.config.ts | 9 --------- 1 file changed, 9 deletions(-) diff --git a/MyApp.Client/vite.config.ts b/MyApp.Client/vite.config.ts index 33ead04..572949d 100644 --- a/MyApp.Client/vite.config.ts +++ b/MyApp.Client/vite.config.ts @@ -17,14 +17,6 @@ const DEPLOY_API = process.env.KAMAL_DEPLOY_HOST // Now use it for API_URL const API_URL = isProd ? DEPLOY_API : (buildLocal ? '' : target) -// Only required if accessing vite directly, e.g. http://localhost:5173 -const proxy = { - '^/api': { - target, - secure: false - } -} - export default defineConfig({ define: { apiBaseUrl: `"${API_URL}"` }, plugins: [ @@ -42,6 +34,5 @@ export default defineConfig({ server: { host: true, // Listen on all interfaces (both IPv4 and IPv6) open: false, - proxy, } }) From 6d8b24472fb7b857171087397fb3f04010f3ab5b Mon Sep 17 00:00:00 2001 From: Demis Bellot Date: Sat, 6 Dec 2025 22:14:04 +0800 Subject: [PATCH 26/38] update styles --- MyApp.Client/src/styles/index.css | 2 +- MyApp/Areas/Identity/Pages/Account/Login.cshtml | 6 +++--- MyApp/Pages/Shared/Header.cshtml | 1 - MyApp/tailwind.input.css | 2 +- 4 files changed, 5 insertions(+), 6 deletions(-) diff --git a/MyApp.Client/src/styles/index.css b/MyApp.Client/src/styles/index.css index aebf237..f4796a8 100644 --- a/MyApp.Client/src/styles/index.css +++ b/MyApp.Client/src/styles/index.css @@ -55,7 +55,7 @@ /* override element defaults */ b, strong { font-weight:600; } -[type='button'],button[type='submit']{cursor:pointer} +[type='button'],button[type='submit'],button[href]{cursor:pointer} /*typography*/ .prose pre::-webkit-scrollbar, .prose code::-webkit-scrollbar { diff --git a/MyApp/Areas/Identity/Pages/Account/Login.cshtml b/MyApp/Areas/Identity/Pages/Account/Login.cshtml index 4b1305e..9a14a42 100644 --- a/MyApp/Areas/Identity/Pages/Account/Login.cshtml +++ b/MyApp/Areas/Identity/Pages/Account/Login.cshtml @@ -17,7 +17,7 @@

Sign In

-
+
@@ -45,8 +45,8 @@
- + class="h-4 w-4 rounded border-gray-300 dark:border-gray-600 dark:bg-gray-800 text-indigo-600 focus:ring-indigo-500"> +
diff --git a/MyApp/Pages/Shared/Header.cshtml b/MyApp/Pages/Shared/Header.cshtml index d29b19c..b02e152 100644 --- a/MyApp/Pages/Shared/Header.cshtml +++ b/MyApp/Pages/Shared/Header.cshtml @@ -31,7 +31,6 @@ } - RenderNavLink("/bookings-auto", "Bookings"); if (!isAuthenticated) { RenderLinkButton("/Identity/Account/Login", "Sign In"); diff --git a/MyApp/tailwind.input.css b/MyApp/tailwind.input.css index d3ca016..00d50ae 100644 --- a/MyApp/tailwind.input.css +++ b/MyApp/tailwind.input.css @@ -155,7 +155,7 @@ [type='file']:focus{outline:1px auto -webkit-focus-ring-color;} [type='color']{height:2.4rem;padding:2px 3px} [type='range']{height:2.4rem} - [type='button'],button[type='submit']{cursor:pointer} + [type='button'],button[type='submit'],button[href]{cursor:pointer} @media (min-width: 640px) { [type='text'],[type='email'],[type='url'],[type='password'],[type='number'],[type='date'],[type='datetime-local'],[type='month'],[type='week'],[type='search'],[type='tel'],[type='time'],[type='color'],[multiple],textarea,select { From 6afab2ea110e8b9b62b00d29027cfe9df1b31525 Mon Sep 17 00:00:00 2001 From: Demis Bellot Date: Sun, 7 Dec 2025 00:16:28 +0800 Subject: [PATCH 27/38] update --- MyApp.Client/src/App.tsx | 4 ++-- MyApp.Client/src/profile.tsx | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/MyApp.Client/src/App.tsx b/MyApp.Client/src/App.tsx index 600a2d8..314dbeb 100644 --- a/MyApp.Client/src/App.tsx +++ b/MyApp.Client/src/App.tsx @@ -46,8 +46,8 @@ function App() {
) - : ( - Sign In + : ( + 🙍🏻‍♂️ My Profile ) }
diff --git a/MyApp.Client/src/profile.tsx b/MyApp.Client/src/profile.tsx index 676d502..3a6dfaa 100644 --- a/MyApp.Client/src/profile.tsx +++ b/MyApp.Client/src/profile.tsx @@ -1,10 +1,10 @@ import Page from "@/components/Page" -import { PrimaryButton, SecondaryButton, TextLink } from "@servicestack/react" +import { PrimaryButton, SecondaryButton } from "@servicestack/react" import { appAuth, ValidateAuth } from "@/lib/auth" function Profile() { const { user, signOut } = appAuth() - return ( + return (
User Profile
{user.displayName}
@@ -18,9 +18,9 @@ function Profile() { Identity Auth Account - + 🏠 Home - +
) } From e152b14621942f94baf62dd6017e1fa9db4373ef Mon Sep 17 00:00:00 2001 From: Demis Bellot Date: Sun, 7 Dec 2025 11:59:23 +0800 Subject: [PATCH 28/38] Update README.md --- README.md | 135 ++++++++++++++++++++++++++++++++++++++---------------- 1 file changed, 95 insertions(+), 40 deletions(-) diff --git a/README.md b/README.md index 1498eb2..d11ebbf 100644 --- a/README.md +++ b/README.md @@ -68,7 +68,7 @@ dotnet watch - **ASP.NET Core Identity** - Complete authentication & authorization system - **Entity Framework Core** - For Identity data management - **OrmLite** - ServiceStack's fast, lightweight Typed ORM for application data -- **SQLite** - Default database (easily upgradable to PostgreSQL/SQL Server/MySQL) +- **SQLite** - Default database - [Upgrade to PostgreSQL/SQL Server/MySQL](#upgrading-to-enterprise-database) ## Major Features @@ -92,7 +92,7 @@ dotnet watch - Command pattern for job execution - Email sending via background jobs - Recurring job scheduling support -- Upgradable to `DatabaseJobsFeature` for enterprise RDBMS +- Uses monthly rolling Sqlite databases by default - [Upgrade to PostgreSQL/SQL Server/MySQL](#upgrading-to-enterprise-database) ### 4. Developer Experience - **Admin UI** at `/admin-ui` for App management @@ -186,6 +186,7 @@ npm run test:ui # Run tests with UI npm run test:run # Run tests once ``` + ## Configuration ### Key Configuration Files @@ -266,6 +267,73 @@ npx add-in ef-postgres npx add-in db-identity ``` +## AutoQuery CRUD Dev Workflow + +For Rapid Development simple [TypeScript Data Models](https://docs.servicestack.net/autoquery/okai-models) can be used to generate C# AutoQuery APIs and DB Migrations. + +### Cheat Sheet + +### Create a new Table + +Create a new Table use `init `, e.g: + +```bash +npx okai init Table +``` + +This will generate an empty `MyApp.ServiceModel/
.d.ts` file along with stub AutoQuery APIs and DB Migration implementations. + +### Use AI to generate the TypeScript Data Model + +Or to get you started quickly you can also use AI to generate the initial TypeScript Data Model with: + +```bash +npx okai "Table to store Customer Stripe Subscriptions" +``` + +This launches a TUI that invokes ServiceStack's okai API to fire multiple concurrent requests to frontier cloud +and OSS models to generate the TypeScript Data Models required to implement this feature. +You'll be able to browse and choose which of the AI Models you prefer which you can accept by pressing `a` +to `(a) accept`. These are the data models [Claude Sonnet 4.5 generated](https://servicestack.net/text-to-blazor?id=1764337230546) for this prompt. + +#### Regenerate AutoQuery APIs and DB Migrations + +After modifying the `Table.d.ts` TypeScript Data Model to include the desired fields, re-run the `okai` tool to re-generate the AutoQuery APIs and DB Migrations: + +```bash +npx okai Table.d.ts +``` + +> Command can be run anywhere within your Solution + +After you're happy with your Data Model you can run DB Migrations to run the DB Migration and create your RDBMS Table: + +```bash +npm run migrate +``` + +#### Making changes after first migration + +If you want to make further changes to your Data Model, you can re-run the `okai` tool to update the AutoQuery APIs and DB Migrations, then run the `rerun:last` npm script to drop and re-run the last migration: + +```bash +npm run rerun:last +``` + +#### Removing a Data Model and all generated code + +If you changed your mind and want to get rid of the RDBMS Table you can revert the last migration: + +```bash +npm run revert:last +``` + +Which will drop the table and then you can get rid of the AutoQuery APIs, DB Migrations and TypeScript Data model with: + +```bash +npx okai rm Transaction.d.ts +``` + ## Deployment ### Docker + Kamal @@ -316,58 +384,45 @@ These are inferred from the GitHub Action context and don't need to be configure - **GitHub Container Registry** integration - **Volume persistence** for App_Data including any SQLite database +## AI-Assisted Development with CLAUDE.md -## AutoQuery CRUD Dev Workflow - -For Rapid Development simple [TypeScript Data Models](https://docs.servicestack.net/autoquery/okai-models) can be used to generate C# AutoQuery APIs and DB Migrations. - -### Cheat Sheet - -Create a new Table use `init
`, e.g: - -```bash -npx okai init Table -``` - -This will generate an empty `MyApp.ServiceModel/
.d.ts` file along with stub AutoQuery APIs and DB Migration implementations. - -#### Regenerate AutoQuery APIs and DB Migrations +As part of our objectives of improving developer experience and embracing modern AI-assisted development workflows - all new .NET SPA templates include a comprehensive `AGENTS.md` file designed to optimize AI-assisted development workflows. -After modifying the TypeScript Data Model to include the desired fields, re-run the `okai` tool to re-generate the AutoQuery APIs and DB Migrations: +### What is CLAUDE.md? -```bash -npx okai Table.d.ts -``` +`CLAUDE.md` and [AGENTS.md](https://agents.md) onboards Claude (and other AI assistants) to your codebase by using a structured documentation file that provides it with complete context about your project's architecture, conventions, and technology choices. This enables more accurate code generation, better suggestions, and faster problem-solving. -> Command can be run anywhere within your Solution +### What's Included -After you're happy with your Data Model you can run DB Migrations to run the DB Migration and create your RDBMS Table: +Each template's `AGENTS.md` contains: -```bash -npm run migrate -``` +- **Project Architecture Overview** - Technology stack, design patterns, and key architectural decisions +- **Project Structure** - Gives Claude a map of the codebase +- **ServiceStack Conventions** - DTO patterns, Service implementation, AutoQuery, Authentication, and Validation +- **API Integration** - TypeScript DTO generation, API client usage, component patterns, and form handling +- **Database Patterns** - OrmLite setup, migrations, and data access patterns +- **Common Development Tasks** - Step-by-step guides for adding APIs, implementing features, and extending functionality +- **Testing & Deployment** - Test patterns and deployment workflows -#### Making changes after first migration +### Extending with Project-Specific Details -If you want to make further changes to your Data Model, you can re-run the `okai` tool to update the AutoQuery APIs and DB Migrations, then run the `rerun:last` npm script to drop and re-run the last migration: +The existing `CLAUDE.md` serves as a solid foundation, but for best results, you should extend it with project-specific details like the purpose of the project, key parts and features of the project and any unique conventions you've adopted. -```bash -npm run rerun:last -``` +### Benefits -#### Removing a Data Model and all generated code +- **Faster Onboarding** - New developers (and AI assistants) understand project conventions immediately +- **Consistent Code Generation** - AI tools generate code following your project's patterns +- **Better Context** - AI assistants can reference specific ServiceStack patterns and conventions +- **Reduced Errors** - Clear documentation of framework-specific conventions +- **Living Documentation** - Keep it updated as your project evolves -If you changed your mind and want to get rid of the RDBMS Table you can revert the last migration: +### How to Use -```bash -npm run revert:last -``` +Claude Code and most AI Assistants already support automatically referencing `CLAUDE.md` and `AGENTS.md` files, for others you can just include it in your prompt context when asking for help, e.g: -Which will drop the table and then you can get rid of the AutoQuery APIs, DB Migrations and TypeScript Data model with: +> Using my project's AGENTS.md, can you help me add a new AutoQuery API for managing Products? -```bash -npx okai rm Transaction.d.ts -``` +The AI will understand your App's ServiceStack conventions, React setup, and project structure, providing more accurate and contextual assistance. ## Ideal Use Cases From 202b10dba009de3fbac49b83eca58ead26dbfefa Mon Sep 17 00:00:00 2001 From: Demis Bellot Date: Sun, 7 Dec 2025 18:15:55 +0800 Subject: [PATCH 29/38] Update Footer --- MyApp.Client/src/App.tsx | 6 ++-- MyApp.Client/src/components/Footer.tsx | 41 ++++++++++++++++++++++++++ MyApp/Pages/Shared/Footer.cshtml | 39 +++++++++++++++--------- 3 files changed, 70 insertions(+), 16 deletions(-) create mode 100644 MyApp.Client/src/components/Footer.tsx diff --git a/MyApp.Client/src/App.tsx b/MyApp.Client/src/App.tsx index 314dbeb..7807374 100644 --- a/MyApp.Client/src/App.tsx +++ b/MyApp.Client/src/App.tsx @@ -5,10 +5,11 @@ import { AutoUis, ShellCommand, ReactLogo, TailwindLogo, TypeScriptLogo, ViteLog import HelloApi from './components/HelloApi' import { appAuth } from './lib/auth' import { Link } from 'react-router-dom' +import Footer from './components/Footer' function App() { const { user, signOut } = appAuth() - return ( + return (<>
- ) +
+ ) } export default App diff --git a/MyApp.Client/src/components/Footer.tsx b/MyApp.Client/src/components/Footer.tsx new file mode 100644 index 0000000..4adf180 --- /dev/null +++ b/MyApp.Client/src/components/Footer.tsx @@ -0,0 +1,41 @@ + +const Footer = () => { + return ( + + ) +} + +export default Footer diff --git a/MyApp/Pages/Shared/Footer.cshtml b/MyApp/Pages/Shared/Footer.cshtml index a57550f..1b2866d 100644 --- a/MyApp/Pages/Shared/Footer.cshtml +++ b/MyApp/Pages/Shared/Footer.cshtml @@ -1,23 +1,34 @@ -
-
-
-

- A ServiceStack Project + From f54e2eb72346dfedeb3f91c13a0d5131a72efdfe Mon Sep 17 00:00:00 2001 From: Demis Bellot Date: Mon, 8 Dec 2025 19:07:46 +0800 Subject: [PATCH 30/38] fix spelling --- AGENTS.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 43b8756..d6c3590 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -77,7 +77,7 @@ npx okai rm Table.d.ts ### Hybrid Development/Production Model **Development Mode:** -- `dotnet watch` from MyApp starts .NET (port 5001) and Vite dev server (port 5173), accesible via `https://localhost:5001` +- `dotnet watch` from MyApp starts .NET (port 5001) and Vite dev server (port 5173), accessible via `https://localhost:5001` - ASP.NET Core proxies requests to Vite dev server via `NodeProxy` (configured in [Program.cs](MyApp/Program.cs#L41)) - Hot Module Replacement (HMR) enabled via WebSocket proxying using `MapNotFoundToNode`, `MapViteHmr`, `RunNodeProcess`, `MapFallbackToNode` in [Program.cs](MyApp/Program.cs) @@ -368,7 +368,7 @@ AutoQuery also matches on pluralized fields where `Ids` matches `Id` and applies const api = client.api(new QueryBookings({ ids:[1,2,3] })) ``` -Multiple Request DTO properties applies mutliple **AND** filters, e.g: +Multiple Request DTO properties applies multiple **AND** filters, e.g: ```typescript const api = client.api(new QueryBookings({ minCost:100, ids:[1,2,3] })) @@ -444,7 +444,7 @@ The `api` and `apiVoid` APIs return an `ApiResult` which holds both su ```typescript const api = await client.api(new Hello({ name })) if (api.succeeded) { - console.log(`The API succeded:`, api.response) + console.log(`The API succeeded:`, api.response) } else if (api.error) { console.log(`The API failed:`, api.error) } @@ -457,7 +457,7 @@ const submit = async (e: React.FormEvent) => { const form = e.currentTarget as HTMLFormElement const api = await client.apiForm(new CreateContact(), new FormData(form)) if (api.succeeded) { - console.log(`The API succeded:`, api.response) + console.log(`The API succeeded:`, api.response) } else if (api.error) { console.log(`The API failed:`, api.error) } @@ -586,7 +586,7 @@ Configured in [Configure.BackgroundJobs.cs](MyApp/Configure.BackgroundJobs.cs) u - `npx okai Feature.d.ts` - `npm run migrate` -Docs: [AutoQuery Dev Worfklow](https://react-templates.net/docs/autoquery/dev-workflow) +Docs: [AutoQuery Dev Workflow](https://react-templates.net/docs/autoquery/dev-workflow) ## Admin Features From 3b206aa996a1724e93d2593df7b01a1adc550c69 Mon Sep 17 00:00:00 2001 From: Demis Bellot Date: Sat, 13 Dec 2025 02:34:19 +0800 Subject: [PATCH 31/38] upgrade npm deps --- MyApp.Client/package.json | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/MyApp.Client/package.json b/MyApp.Client/package.json index 617a403..a2da7ab 100644 --- a/MyApp.Client/package.json +++ b/MyApp.Client/package.json @@ -15,28 +15,28 @@ "@servicestack/react": "^2.0.17", "@tailwindcss/forms": "^0.5.10", "clsx": "^2.1.1", - "react": "^19.2.1", - "react-dom": "^19.2.1", + "react": "^19.2.3", + "react-dom": "^19.2.3", "react-net-templates": "^0.0.1", - "react-router-dom": "^7.10.0", + "react-router-dom": "^7.10.1", "tailwind-merge": "^3.4.0" }, "devDependencies": { "@eslint/js": "^9.39.1", - "@tailwindcss/postcss": "^4.1.17", - "@tailwindcss/vite": "^4.1.17", - "@types/node": "^24.10.1", + "@tailwindcss/postcss": "^4.1.18", + "@tailwindcss/vite": "^4.1.18", + "@types/node": "^25.0.1", "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", - "@vitejs/plugin-react": "^5.1.1", + "@vitejs/plugin-react": "^5.1.2", "eslint": "^9.39.1", "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.4.24", "globals": "^16.5.0", "postcss": "^8.5.6", - "tailwindcss": "^4.1.17", + "tailwindcss": "^4.1.18", "typescript": "~5.9.3", - "typescript-eslint": "^8.48.1", - "vite": "^7.2.6" + "typescript-eslint": "^8.49.0", + "vite": "^7.2.7" } } From 3e54569f6d79b4a2b23c9404a31b21248a5fbca1 Mon Sep 17 00:00:00 2001 From: Demis Bellot Date: Sun, 14 Dec 2025 00:13:32 +0800 Subject: [PATCH 32/38] Update GitHub Actions --- .github/workflows/build-container.yml | 30 +++++---------------- .github/workflows/release.yml | 27 ++++++++++++------- .kamal/secrets | 1 + config/deploy.yml | 28 ++++++++++--------- load-env.sh | 39 --------------------------- 5 files changed, 41 insertions(+), 84 deletions(-) delete mode 100644 load-env.sh diff --git a/.github/workflows/build-container.yml b/.github/workflows/build-container.yml index 957e094..12651e6 100644 --- a/.github/workflows/build-container.yml +++ b/.github/workflows/build-container.yml @@ -27,19 +27,17 @@ jobs: - name: Set up environment variables run: | - echo "image_repository_name=$(echo ${{ github.repository }} | tr '[:upper:]' '[:lower:]')" >> $GITHUB_ENV - echo "repository_name=$(echo ${{ github.repository }} | cut -d '/' -f 2)" >> $GITHUB_ENV - echo "repository_name_lower=$(echo ${{ github.repository }} | cut -d '/' -f 2 | tr '[:upper:]' '[:lower:]')" >> $GITHUB_ENV - echo "org_name=$(echo ${{ github.repository }} | cut -d '/' -f 1)" >> $GITHUB_ENV + echo "IMAGE=ghcr.io/$(echo ${{ github.repository }} | tr '[:upper:]' '[:lower:]')" >> $GITHUB_ENV + repo_name="$(echo ${{ github.repository }} | cut -d '/' -f 2)" - # Set SERVICE_LABEL: derive from GITHUB_REPOSITORY (replace dots with dashes) - echo "SERVICE_LABEL=$(echo ${{ github.repository }} | cut -d '/' -f 2 | tr '.' '-')" >> $GITHUB_ENV + # Set SERVICE: derive from repo name (replace dots with dashes) + echo "SERVICE=$(echo $repo_name | tr '[:upper:]' '[:lower:]' | tr '.' '-')" >> $GITHUB_ENV # Set KAMAL_DEPLOY_HOST: use secret if available, otherwise use repository name if [ -n "${{ secrets.KAMAL_DEPLOY_HOST }}" ]; then DEPLOY_HOST="${{ secrets.KAMAL_DEPLOY_HOST }}" else - DEPLOY_HOST="$(echo ${{ github.repository }} | cut -d '/' -f 2)" + DEPLOY_HOST="$repo_name" fi # Validate KAMAL_DEPLOY_HOST contains at least one '.' @@ -56,7 +54,7 @@ jobs: KAMAL_DEPLOY_IP: ${{ secrets.KAMAL_DEPLOY_IP }} if: env.KAMAL_DEPLOY_IP != null run: | - sed -i 's###g' MyApp/MyApp.csproj + sed -i 's###g' MyApp/MyApp.csproj - name: Check for Client directory and package.json id: check_client @@ -83,20 +81,6 @@ jobs: working-directory: ./MyApp.Client run: npm run build - - name: Install x tool - run: dotnet tool install -g x - - - name: Apply Production AppSettings - env: - APPSETTINGS_PATCH: ${{ secrets.APPSETTINGS_PATCH }} - if: env.APPSETTINGS_PATCH != null - working-directory: ./MyApp - run: | - cat <> appsettings.json.patch - ${{ secrets.APPSETTINGS_PATCH }} - EOF - x patch appsettings.json.patch - - name: Login to GitHub Container Registry uses: docker/login-action@v3 with: @@ -115,7 +99,7 @@ jobs: KAMAL_DEPLOY_HOST: ${{ secrets.KAMAL_DEPLOY_HOST }} run: | dotnet publish --os linux --arch x64 -c Release \ - -p:ContainerRepository=${{ env.image_repository_name }} \ + -p:ContainerRepository=${{ env.IMAGE }} \ -p:ContainerRegistry=ghcr.io -p:ContainerImageTags=latest \ -p:ContainerPort=80 \ -p:ContainerEnvironmentVariable="SERVICESTACK_LICENSE=${{ env.SERVICESTACK_LICENSE }}" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6e97724..a9631c9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -15,6 +15,7 @@ on: env: DOCKER_BUILDKIT: 1 SERVICESTACK_LICENSE: ${{ secrets.SERVICESTACK_LICENSE }} + APPSETTINGS_JSON: ${{ secrets.APPSETTINGS_JSON }} KAMAL_DEPLOY_IP: ${{ secrets.KAMAL_DEPLOY_IP }} KAMAL_DEPLOY_HOST: ${{ secrets.KAMAL_DEPLOY_HOST }} KAMAL_REGISTRY_USERNAME: ${{ github.actor }} @@ -28,12 +29,18 @@ jobs: - name: Checkout code uses: actions/checkout@v5 + - name: Encode APPSETTINGS_JSON for runtime + if: env.APPSETTINGS_JSON != null + run: | + # Base64 encode to avoid shell/YAML quoting issues; keep as a single env var. + b64=$(printf '%s' "$APPSETTINGS_JSON" | base64 -w0) + echo "APPSETTINGS_JSON_BASE64=$b64" >> $GITHUB_ENV + - name: Set up environment variables run: | - echo "image_repository_name=$(echo ${{ github.repository }} | tr '[:upper:]' '[:lower:]')" >> $GITHUB_ENV - echo "repository_name=$(echo ${{ github.repository }} | cut -d '/' -f 2)" >> $GITHUB_ENV - echo "repository_name_lower=$(echo ${{ github.repository }} | cut -d '/' -f 2 | tr '[:upper:]' '[:lower:]')" >> $GITHUB_ENV - echo "org_name=$(echo ${{ github.repository }} | cut -d '/' -f 1)" >> $GITHUB_ENV + echo "IMAGE=ghcr.io/$(echo ${{ github.repository }} | tr '[:upper:]' '[:lower:]')" >> $GITHUB_ENV + repo_name="$(echo ${{ github.repository }} | cut -d '/' -f 2)" + echo "SERVICE=$(echo $repo_name | tr '[:upper:]' '[:lower:]' | tr '.' '-')" >> $GITHUB_ENV if find . -maxdepth 2 -type f -name "Configure.Db.Migrations.cs" | grep -q .; then echo "HAS_MIGRATIONS=true" >> $GITHUB_ENV else @@ -73,14 +80,14 @@ jobs: - name: Ensure directories exist with correct permissions run: | echo "Creating directories with correct permissions" - kamal server exec "mkdir -p /opt/docker/${{ env.repository_name }}/App_Data /opt/docker/${{ env.repository_name }}/initdb.d" + kamal server exec "mkdir -p /opt/docker/${{ env.SERVICE }}/App_Data /opt/docker/${{ env.SERVICE }}/initdb.d" echo "Setting app file permissions" - kamal server exec "chown -R 1654:1654 /opt/docker/${{ env.repository_name }}/App_Data /opt/docker/${{ env.repository_name }}/initdb.d" + kamal server exec "chown -R 1654:1654 /opt/docker/${{ env.SERVICE }}/App_Data /opt/docker/${{ env.SERVICE }}/initdb.d" - name: Check if first run and execute kamal app boot if necessary run: | - FIRST_RUN_FILE="~/first-run/${{ env.repository_name }}" + FIRST_RUN_FILE="~/first-run/${{ env.SERVICE }}" if ! kamal server exec -q "test -f $FIRST_RUN_FILE"; then kamal server exec -q "mkdir -p ~/first-run && touch $FIRST_RUN_FILE" || true @@ -88,7 +95,7 @@ jobs: echo "Initializing DB with INIT_DB_SQL secret..." # Save the SQL content to a temporary file echo "${{ env.INIT_DB_SQL }}" > init-db.sql - cat init-db.sql | kamal server exec -i "cat > /opt/docker/${{ env.repository_name }}/initdb.d/${{ env.repository_name }}.sql" && rm init-db.sql || true + cat init-db.sql | kamal server exec -i "cat > /opt/docker/${{ env.SERVICE }}/initdb.d/${{ env.SERVICE }}.sql" && rm init-db.sql || true fi # Start all kamal accessories kamal accessory boot all || true @@ -101,13 +108,13 @@ jobs: - name: Verify file permissions before deploy run: | - kamal server exec --no-interactive "chown -R 1654:1654 /opt/docker/${{ env.repository_name }}/App_Data /opt/docker/${{ env.repository_name }}/initdb.d" + kamal server exec --no-interactive "chown -R 1654:1654 /opt/docker/${{ env.SERVICE }}/App_Data /opt/docker/${{ env.SERVICE }}/initdb.d" - name: Deploy with Kamal run: | kamal lock release -v kamal server exec --no-interactive 'echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin' - kamal server exec --no-interactive 'docker pull ghcr.io/${{ env.image_repository_name }}:latest' + kamal server exec --no-interactive 'docker pull ${{ env.IMAGE }}:latest' kamal deploy -P --version latest - name: Migration diff --git a/.kamal/secrets b/.kamal/secrets index bd4a025..cba9223 100644 --- a/.kamal/secrets +++ b/.kamal/secrets @@ -6,6 +6,7 @@ KAMAL_REGISTRY_PASSWORD=$KAMAL_REGISTRY_PASSWORD KAMAL_REGISTRY_USERNAME=$KAMAL_REGISTRY_USERNAME SERVICESTACK_LICENSE=$SERVICESTACK_LICENSE +APPSETTINGS_JSON_BASE64=$APPSETTINGS_JSON_BASE64 # Option 2: Read secrets via a command # RAILS_MASTER_KEY=$(cat config/master.key) diff --git a/config/deploy.yml b/config/deploy.yml index 67e8fd5..3416875 100644 --- a/config/deploy.yml +++ b/config/deploy.yml @@ -1,19 +1,22 @@ +# <% require "dotenv"; Dotenv.load(".env") %> # Kamal deploy config for this repository. Uses environment variables: -# - GITHUB_REPOSITORY (e.g. acme/example.org) - from GitHub Action ${github.repository} -# - KAMAL_REGISTRY_USERNAME (e.g. my-user) - from GitHub Action ${github.actor} -# - KAMAL_REGISTRY_PASSWORD ($GITHUB_TOKEN) - from GitHub Action ${secrets.GITHUB_TOKEN} -# - KAMAL_DEPLOY_IP (e.g. 100.100.100.100) - from GitHub Action Secret -# - KAMAL_DEPLOY_HOST (e.g. example.org) - from GitHub Action Secret -# - POSTGRES_PASSWORD (e.g. random-password) - from GitHub Action Secret - +# - GITHUB_REPOSITORY (e.g. acme/example.org) - from GitHub Action ${github.repository} +# SERVICE (e.g. example-org) +# IMAGE (e.g. ghcr.io/acme/example.org) +# - KAMAL_REGISTRY_USERNAME (e.g. my-user) - from GitHub Action ${github.actor} +# - KAMAL_REGISTRY_PASSWORD ($GITHUB_TOKEN) - from GitHub Action ${secrets.GITHUB_TOKEN} +# - KAMAL_DEPLOY_IP (e.g. 100.100.100.100) - from GitHub Action Secret +# - KAMAL_DEPLOY_HOST (e.g. example.org) - from GitHub Action Secret +# - POSTGRES_PASSWORD (e.g. random-password) - from GitHub Action Secret +# # Using environment variables keeps this configuration reusable across multiple apps. -# For a simpler, app-specific setup, you can replace them with hard-coded values. +# For a simpler app-specific setup (without needing .env), they can be replaced with hard-coded values. # Name of your application. Used to uniquely configure containers. (e.g. example-org) -service: <%= ENV['GITHUB_REPOSITORY'].to_s.split('/').last.tr('.', '-') %> +service: <%= ENV['SERVICE'] %> -# Name of the container image. (e.g. ghcr.io/acne/example.org) -image: ghcr.io/<%= ENV['GITHUB_REPOSITORY'].to_s.downcase %> +# Name of the container image. (e.g. ghcr.io/acme/example.org) +image: <%= ENV['IMAGE'] %> # Required for use of ASP.NET Core with Kamal-Proxy. env: @@ -22,6 +25,7 @@ env: # secrets from ./kamal/secrets secret: - SERVICESTACK_LICENSE + - APPSETTINGS_JSON_BASE64 # Deploy to these servers. servers: @@ -56,7 +60,7 @@ builder: arch: amd64 volumes: - - "/opt/docker/<%= ENV['GITHUB_REPOSITORY'].to_s.split('/').last %>/App_Data:/app/App_Data" + - "/opt/docker/<%= ENV['SERVICE'] %>/App_Data:/app/App_Data" #accessories: # litestream: diff --git a/load-env.sh b/load-env.sh deleted file mode 100644 index c020335..0000000 --- a/load-env.sh +++ /dev/null @@ -1,39 +0,0 @@ -#!/usr/bin/env bash - -# Load variables from the .env file into the current shell so they're available to kamal and other commands. -# NOTE: This script must be *sourced*, not executed, e.g.: -# source load-env.sh -# or -# . ./load-env.sh - -# Add the necessary variables used by ./config/deploy.yml to .env file, e.g: -# - GITHUB_REPOSITORY=org/repo # Repo name used to derive image/service name and folder paths -# - KAMAL_DEPLOY_IP=100.100.100.100 # IP address of server to deploy to -# - KAMAL_DEPLOY_HOST=www.example.org # domain name of website -# - KAMAL_REGISTRY_USERNAME=user # Container registry credentials (for ghcr.io) -# - GITHUB_PACKAGES_TOKEN=ghp_xxx -# Login with: -# echo $KAMAL_REGISTRY_PASSWORD | docker login ghcr.io -u mythz --password-stdin - -# If this script is run directly (./load-env.sh), print a warning because -# environment changes will not persist in the parent shell. -if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then - echo "[load-env] This script must be sourced to affect the current shell:" >&2 - echo " source load-env.sh" >&2 - exit 1 -fi - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -ENV_FILE="$SCRIPT_DIR/.env" - -if [[ ! -f "$ENV_FILE" ]]; then - echo "[load-env] No .env file found at: $ENV_FILE" >&2 - return 1 -fi - -# Auto-export all variables defined while the script runs -set -a -source "$ENV_FILE" -set +a - -echo "[load-env] Loaded environment variables from $ENV_FILE" From b97fcf376e8ec2782507762d2d3d18e25e62e70c Mon Sep 17 00:00:00 2001 From: Demis Bellot Date: Sun, 14 Dec 2025 01:38:45 +0800 Subject: [PATCH 33/38] Update .gitignore --- .gitignore | 321 ++++++++++------------------------------------------- 1 file changed, 59 insertions(+), 262 deletions(-) diff --git a/.gitignore b/.gitignore index 0de7e51..3baccdb 100644 --- a/.gitignore +++ b/.gitignore @@ -1,23 +1,48 @@ -## Ignore Visual Studio temporary files, build results, and -## files generated by popular Visual Studio add-ons. -## -## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. -# Custom +# dotnet +.env dist/ -coverage/ -package-lock.json App_Data/ wwwroot/ - -# User-specific files -*.suo +.vscode/ +.idea/ *.user -*.userosscache -*.sln.docstates +*.tsbuildinfo +#sqlite +.vs/ +bin/ +obj/ +*.db +*.db-shm +*.db-wal +*.db-journal +*.sqlite +*.sqlite +*.sqlite-shm +*.sqlite-wal +Configure.secrets.cs +appsettings.Production.* + +# dependencies +node_modules/ +.pnp +.pnp.js -# User-specific files (MonoDevelop/Xamarin Studio) -*.userprefs +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# Next.js build output +.next +out + +# Nuxt.js build / generate output +.nuxt +dist # Build results [Dd]ebug/ @@ -26,272 +51,44 @@ wwwroot/ [Rr]eleases/ x64/ x86/ +[Ww][Ii][Nn]32/ +[Aa][Rr][Mm]/ +[Aa][Rr][Mm]64/ bld/ [Bb]in/ [Oo]bj/ [Ll]og/ - -# Visual Studio 2015 cache/options directory -.vs/ -# Uncomment if you have tasks that create the project's static files in wwwroot -#wwwroot/ - -# MSTest test Results -[Tt]est[Rr]esult*/ -[Bb]uild[Ll]og.* - -# NUNIT -*.VisualState.xml -TestResult.xml - -# Build Results of an ATL Project -[Dd]ebugPS/ -[Rr]eleasePS/ -dlldata.c +[Ll]ogs/ # .NET Core project.lock.json project.fragment.lock.json artifacts/ -#**/Properties/launchSettings.json - -*_i.c -*_p.c -*_i.h -*.ilk -*.meta -*.obj -*.pch -*.pdb -*.pgc -*.pgd -*.rsp -*.sbr -*.tlb -*.tli -*.tlh -*.tmp -*.tmp_proj -*.log -*.vspscc -*.vssscc -.builds -*.pidb -*.svclog -*.scc - -# Chutzpah Test files -_Chutzpah* - -# Visual C++ cache files -ipch/ -*.aps -*.ncb -*.opendb -*.opensdf -*.sdf -*.cachefile -*.VC.db -*.VC.VC.opendb - -# Visual Studio profiler -*.psess -*.vsp -*.vspx -*.sap - -# TFS 2012 Local Workspace -$tf/ - -# Guidance Automation Toolkit -*.gpState - -# ReSharper is a .NET coding add-in -_ReSharper*/ -*.[Rr]e[Ss]harper -*.DotSettings.user - -# JustCode is a .NET coding add-in -.JustCode - -# TeamCity is a build add-in -_TeamCity* - -# DotCover is a Code Coverage Tool -*.dotCover - -# Visual Studio code coverage results -*.coverage -*.coveragexml - -# NCrunch -_NCrunch_* -.*crunch*.local.xml -nCrunchTemp_* - -# MightyMoose -*.mm.* -AutoTest.Net/ - -# Web workbench (sass) -.sass-cache/ - -# Installshield output folder -[Ee]xpress/ -# DocProject is a documentation generator add-in -DocProject/buildhelp/ -DocProject/Help/*.HxT -DocProject/Help/*.HxC -DocProject/Help/*.hhc -DocProject/Help/*.hhk -DocProject/Help/*.hhp -DocProject/Help/Html2 -DocProject/Help/html - -# Click-Once directory -publish/ - -# Publish Web Output -*.[Pp]ublish.xml -*.azurePubxml -# TODO: Comment the next line if you want to checkin your web deploy settings -# but database connection strings (with potential passwords) will be unencrypted -*.pubxml -*.publishproj - -# Microsoft Azure Web App publish settings. Comment the next line if you want to -# checkin your Azure Web App publish settings, but sensitive information contained -# in these scripts will be unencrypted -PublishScripts/ +# ASP.NET Scaffolding +ScaffoldingReadMe.txt # NuGet Packages *.nupkg -# The packages folder can be ignored because of Package Restore -**/packages/* -# except build/, which is used as an MSBuild target. -!**/packages/build/ -# Uncomment if necessary however generally it will be regenerated when needed -#!**/packages/repositories.config -# NuGet v3's project.json files produces more ignorable files -*.nuget.props -*.nuget.targets - -# Microsoft Azure Build Output -csx/ -*.build.csdef +# NuGet Symbol Packages +*.snupkg -# Microsoft Azure Emulator -ecf/ -rcf/ - -# Windows Store app package directories and files -AppPackages/ -BundleArtifacts/ -Package.StoreAssociation.xml -_pkginfo.txt - -# Visual Studio cache files -# files ending in .cache can be ignored -*.[Cc]ache -# but keep track of directories ending in .cache -!*.[Cc]ache/ +# dotenv environment variables file +.env # Others -ClientBin/ ~$* *~ -*.dbmdl -*.dbproj.schemaview -*.jfm -*.pfx -*.publishsettings -orleans.codegen.cs - -# Since there are multiple workflows, uncomment next line to ignore bower_components -# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) -#bower_components/ - -# RIA/Silverlight projects -Generated_Code/ - -# Backup & report files from converting an old project file -# to a newer Visual Studio version. Backup files are not needed, -# because we have git ;-) -_UpgradeReport_Files/ -Backup*/ -UpgradeLog*.XML -UpgradeLog*.htm - -# SQL Server files -*.mdf -*.ldf -*.ndf - -# Business Intelligence projects -*.rdl.data -*.bim.layout -*.bim_*.settings - -# Microsoft Fakes -FakesAssemblies/ - -# GhostDoc plugin setting file -*.GhostDoc.xml - -# Node.js Tools for Visual Studio -.ntvs_analysis.dat -node_modules/ - -# Typescript v1 declaration files -typings/ +CodeCoverage/ -# Visual Studio 6 build log -*.plg +# MSBuild Binary and Structured Log +*.binlog -# Visual Studio 6 workspace options file -*.opt - -# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) -*.vbw - -# Visual Studio LightSwitch build output -**/*.HTMLClient/GeneratedArtifacts -**/*.DesktopClient/GeneratedArtifacts -**/*.DesktopClient/ModelManifest.xml -**/*.Server/GeneratedArtifacts -**/*.Server/ModelManifest.xml -_Pvt_Extensions - -# Paket dependency manager -.paket/paket.exe -paket-files/ - -# FAKE - F# Make -.fake/ - -# JetBrains Rider -.idea/ -*.sln.iml - -# CodeRush -.cr/ - -# Python Tools for Visual Studio (PTVS) -__pycache__/ -*.pyc - -# Cake - Uncomment if you are using it -# tools/** -# !tools/packages.config - -# Telerik's JustMock configuration file -*.jmconfig +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* -# BizTalk build output -*.btp.cs -*.btm.cs -*.odx.cs -*.xsd.cs -MyApp/Configure.secrets.cs -mise.toml +# NUnit +*.VisualState.xml +TestResult.xml +nunit-*.xml \ No newline at end of file From 7a6afbd567bbebd991718e0118ddb34e1998f6fb Mon Sep 17 00:00:00 2001 From: Demis Bellot Date: Sun, 14 Dec 2025 01:38:49 +0800 Subject: [PATCH 34/38] Update ApplicationUser.cs --- MyApp.ServiceInterface/Data/ApplicationUser.cs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/MyApp.ServiceInterface/Data/ApplicationUser.cs b/MyApp.ServiceInterface/Data/ApplicationUser.cs index d4e19cf..608bc51 100644 --- a/MyApp.ServiceInterface/Data/ApplicationUser.cs +++ b/MyApp.ServiceInterface/Data/ApplicationUser.cs @@ -2,6 +2,11 @@ namespace MyApp.Data; +/* After modifying ApplicationUser, delete and recreate the initial EF Core migration with: + rm Migrations/202*.cs Migrations/ApplicationDbContextModelSnapshot.cs + dotnet ef migrations add CreateIdentitySchema +*/ + // Add profile data for application users by adding properties to the ApplicationUser class public class ApplicationUser : IdentityUser { From eb204a9553aa7a96356830d367164c3d6008f5da Mon Sep 17 00:00:00 2001 From: Demis Bellot Date: Sun, 14 Dec 2025 01:38:51 +0800 Subject: [PATCH 35/38] Update package.json --- MyApp/package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/MyApp/package.json b/MyApp/package.json index bcc7a4d..9b63426 100644 --- a/MyApp/package.json +++ b/MyApp/package.json @@ -6,6 +6,7 @@ "ui:dev": "tailwindcss -i ./tailwind.input.css -o ./wwwroot/css/app.css --watch", "ui:build": "tailwindcss -i ./tailwind.input.css -o ./wwwroot/css/app.css --minify", "build": "npm run ui:build", + "secret:prod": "gh secret set APPSETTINGS_JSON < appsettings.Production.json", "migrate": "dotnet run --AppTasks=migrate", "revert:last": "dotnet run --AppTasks=migrate.revert:last", "revert:all": "dotnet run --AppTasks=migrate.revert:all", From 718023f5af8ede06ed2d13543bae43996ba0522c Mon Sep 17 00:00:00 2001 From: Demis Bellot Date: Sat, 14 Feb 2026 09:24:08 +0800 Subject: [PATCH 36/38] Add require Identity jquery lib to /public/lib --- MyApp.Client/public/lib/jquery.validate.js | 1601 +++++++++++++++++ .../public/lib/jquery.validate.unobtrusive.js | 432 +++++ .../Pages/_ValidationScriptsPartial.cshtml | 8 +- 3 files changed, 2037 insertions(+), 4 deletions(-) create mode 100644 MyApp.Client/public/lib/jquery.validate.js create mode 100644 MyApp.Client/public/lib/jquery.validate.unobtrusive.js diff --git a/MyApp.Client/public/lib/jquery.validate.js b/MyApp.Client/public/lib/jquery.validate.js new file mode 100644 index 0000000..12674b0 --- /dev/null +++ b/MyApp.Client/public/lib/jquery.validate.js @@ -0,0 +1,1601 @@ +/*! + * jQuery Validation Plugin v1.17.0 + * + * https://jqueryvalidation.org/ + * + * Copyright (c) 2017 Jörn Zaefferer + * Released under the MIT license + */ +(function( factory ) { + if ( typeof define === "function" && define.amd ) { + define( ["jquery"], factory ); + } else if (typeof module === "object" && module.exports) { + module.exports = factory( require( "jquery" ) ); + } else { + factory( jQuery ); + } +}(function( $ ) { + +$.extend( $.fn, { + + // https://jqueryvalidation.org/validate/ + validate: function( options ) { + + // If nothing is selected, return nothing; can't chain anyway + if ( !this.length ) { + if ( options && options.debug && window.console ) { + console.warn( "Nothing selected, can't validate, returning nothing." ); + } + return; + } + + // Check if a validator for this form was already created + var validator = $.data( this[ 0 ], "validator" ); + if ( validator ) { + return validator; + } + + // Add novalidate tag if HTML5. + this.attr( "novalidate", "novalidate" ); + + validator = new $.validator( options, this[ 0 ] ); + $.data( this[ 0 ], "validator", validator ); + + if ( validator.settings.onsubmit ) { + + this.on( "click.validate", ":submit", function( event ) { + + // Track the used submit button to properly handle scripted + // submits later. + validator.submitButton = event.currentTarget; + + // Allow suppressing validation by adding a cancel class to the submit button + if ( $( this ).hasClass( "cancel" ) ) { + validator.cancelSubmit = true; + } + + // Allow suppressing validation by adding the html5 formnovalidate attribute to the submit button + if ( $( this ).attr( "formnovalidate" ) !== undefined ) { + validator.cancelSubmit = true; + } + } ); + + // Validate the form on submit + this.on( "submit.validate", function( event ) { + if ( validator.settings.debug ) { + + // Prevent form submit to be able to see console output + event.preventDefault(); + } + function handle() { + var hidden, result; + + // Insert a hidden input as a replacement for the missing submit button + // The hidden input is inserted in two cases: + // - A user defined a `submitHandler` + // - There was a pending request due to `remote` method and `stopRequest()` + // was called to submit the form in case it's valid + if ( validator.submitButton && ( validator.settings.submitHandler || validator.formSubmitted ) ) { + hidden = $( "" ) + .attr( "name", validator.submitButton.name ) + .val( $( validator.submitButton ).val() ) + .appendTo( validator.currentForm ); + } + + if ( validator.settings.submitHandler ) { + result = validator.settings.submitHandler.call( validator, validator.currentForm, event ); + if ( hidden ) { + + // And clean up afterwards; thanks to no-block-scope, hidden can be referenced + hidden.remove(); + } + if ( result !== undefined ) { + return result; + } + return false; + } + return true; + } + + // Prevent submit for invalid forms or custom submit handlers + if ( validator.cancelSubmit ) { + validator.cancelSubmit = false; + return handle(); + } + if ( validator.form() ) { + if ( validator.pendingRequest ) { + validator.formSubmitted = true; + return false; + } + return handle(); + } else { + validator.focusInvalid(); + return false; + } + } ); + } + + return validator; + }, + + // https://jqueryvalidation.org/valid/ + valid: function() { + var valid, validator, errorList; + + if ( $( this[ 0 ] ).is( "form" ) ) { + valid = this.validate().form(); + } else { + errorList = []; + valid = true; + validator = $( this[ 0 ].form ).validate(); + this.each( function() { + valid = validator.element( this ) && valid; + if ( !valid ) { + errorList = errorList.concat( validator.errorList ); + } + } ); + validator.errorList = errorList; + } + return valid; + }, + + // https://jqueryvalidation.org/rules/ + rules: function( command, argument ) { + var element = this[ 0 ], + settings, staticRules, existingRules, data, param, filtered; + + // If nothing is selected, return empty object; can't chain anyway + if ( element == null ) { + return; + } + + if ( !element.form && element.hasAttribute( "contenteditable" ) ) { + element.form = this.closest( "form" )[ 0 ]; + element.name = this.attr( "name" ); + } + + if ( element.form == null ) { + return; + } + + if ( command ) { + settings = $.data( element.form, "validator" ).settings; + staticRules = settings.rules; + existingRules = $.validator.staticRules( element ); + switch ( command ) { + case "add": + $.extend( existingRules, $.validator.normalizeRule( argument ) ); + + // Remove messages from rules, but allow them to be set separately + delete existingRules.messages; + staticRules[ element.name ] = existingRules; + if ( argument.messages ) { + settings.messages[ element.name ] = $.extend( settings.messages[ element.name ], argument.messages ); + } + break; + case "remove": + if ( !argument ) { + delete staticRules[ element.name ]; + return existingRules; + } + filtered = {}; + $.each( argument.split( /\s/ ), function( index, method ) { + filtered[ method ] = existingRules[ method ]; + delete existingRules[ method ]; + } ); + return filtered; + } + } + + data = $.validator.normalizeRules( + $.extend( + {}, + $.validator.classRules( element ), + $.validator.attributeRules( element ), + $.validator.dataRules( element ), + $.validator.staticRules( element ) + ), element ); + + // Make sure required is at front + if ( data.required ) { + param = data.required; + delete data.required; + data = $.extend( { required: param }, data ); + } + + // Make sure remote is at back + if ( data.remote ) { + param = data.remote; + delete data.remote; + data = $.extend( data, { remote: param } ); + } + + return data; + } +} ); + +// Custom selectors +$.extend( $.expr.pseudos || $.expr[ ":" ], { // '|| $.expr[ ":" ]' here enables backwards compatibility to jQuery 1.7. Can be removed when dropping jQ 1.7.x support + + // https://jqueryvalidation.org/blank-selector/ + blank: function( a ) { + return !$.trim( "" + $( a ).val() ); + }, + + // https://jqueryvalidation.org/filled-selector/ + filled: function( a ) { + var val = $( a ).val(); + return val !== null && !!$.trim( "" + val ); + }, + + // https://jqueryvalidation.org/unchecked-selector/ + unchecked: function( a ) { + return !$( a ).prop( "checked" ); + } +} ); + +// Constructor for validator +$.validator = function( options, form ) { + this.settings = $.extend( true, {}, $.validator.defaults, options ); + this.currentForm = form; + this.init(); +}; + +// https://jqueryvalidation.org/jQuery.validator.format/ +$.validator.format = function( source, params ) { + if ( arguments.length === 1 ) { + return function() { + var args = $.makeArray( arguments ); + args.unshift( source ); + return $.validator.format.apply( this, args ); + }; + } + if ( params === undefined ) { + return source; + } + if ( arguments.length > 2 && params.constructor !== Array ) { + params = $.makeArray( arguments ).slice( 1 ); + } + if ( params.constructor !== Array ) { + params = [ params ]; + } + $.each( params, function( i, n ) { + source = source.replace( new RegExp( "\\{" + i + "\\}", "g" ), function() { + return n; + } ); + } ); + return source; +}; + +$.extend( $.validator, { + + defaults: { + messages: {}, + groups: {}, + rules: {}, + errorClass: "error", + pendingClass: "pending", + validClass: "valid", + errorElement: "label", + focusCleanup: false, + focusInvalid: true, + errorContainer: $( [] ), + errorLabelContainer: $( [] ), + onsubmit: true, + ignore: ":hidden", + ignoreTitle: false, + onfocusin: function( element ) { + this.lastActive = element; + + // Hide error label and remove error class on focus if enabled + if ( this.settings.focusCleanup ) { + if ( this.settings.unhighlight ) { + this.settings.unhighlight.call( this, element, this.settings.errorClass, this.settings.validClass ); + } + this.hideThese( this.errorsFor( element ) ); + } + }, + onfocusout: function( element ) { + if ( !this.checkable( element ) && ( element.name in this.submitted || !this.optional( element ) ) ) { + this.element( element ); + } + }, + onkeyup: function( element, event ) { + + // Avoid revalidate the field when pressing one of the following keys + // Shift => 16 + // Ctrl => 17 + // Alt => 18 + // Caps lock => 20 + // End => 35 + // Home => 36 + // Left arrow => 37 + // Up arrow => 38 + // Right arrow => 39 + // Down arrow => 40 + // Insert => 45 + // Num lock => 144 + // AltGr key => 225 + var excludedKeys = [ + 16, 17, 18, 20, 35, 36, 37, + 38, 39, 40, 45, 144, 225 + ]; + + if ( event.which === 9 && this.elementValue( element ) === "" || $.inArray( event.keyCode, excludedKeys ) !== -1 ) { + return; + } else if ( element.name in this.submitted || element.name in this.invalid ) { + this.element( element ); + } + }, + onclick: function( element ) { + + // Click on selects, radiobuttons and checkboxes + if ( element.name in this.submitted ) { + this.element( element ); + + // Or option elements, check parent select in that case + } else if ( element.parentNode.name in this.submitted ) { + this.element( element.parentNode ); + } + }, + highlight: function( element, errorClass, validClass ) { + if ( element.type === "radio" ) { + this.findByName( element.name ).addClass( errorClass ).removeClass( validClass ); + } else { + $( element ).addClass( errorClass ).removeClass( validClass ); + } + }, + unhighlight: function( element, errorClass, validClass ) { + if ( element.type === "radio" ) { + this.findByName( element.name ).removeClass( errorClass ).addClass( validClass ); + } else { + $( element ).removeClass( errorClass ).addClass( validClass ); + } + } + }, + + // https://jqueryvalidation.org/jQuery.validator.setDefaults/ + setDefaults: function( settings ) { + $.extend( $.validator.defaults, settings ); + }, + + messages: { + required: "This field is required.", + remote: "Please fix this field.", + email: "Please enter a valid email address.", + url: "Please enter a valid URL.", + date: "Please enter a valid date.", + dateISO: "Please enter a valid date (ISO).", + number: "Please enter a valid number.", + digits: "Please enter only digits.", + equalTo: "Please enter the same value again.", + maxlength: $.validator.format( "Please enter no more than {0} characters." ), + minlength: $.validator.format( "Please enter at least {0} characters." ), + rangelength: $.validator.format( "Please enter a value between {0} and {1} characters long." ), + range: $.validator.format( "Please enter a value between {0} and {1}." ), + max: $.validator.format( "Please enter a value less than or equal to {0}." ), + min: $.validator.format( "Please enter a value greater than or equal to {0}." ), + step: $.validator.format( "Please enter a multiple of {0}." ) + }, + + autoCreateRanges: false, + + prototype: { + + init: function() { + this.labelContainer = $( this.settings.errorLabelContainer ); + this.errorContext = this.labelContainer.length && this.labelContainer || $( this.currentForm ); + this.containers = $( this.settings.errorContainer ).add( this.settings.errorLabelContainer ); + this.submitted = {}; + this.valueCache = {}; + this.pendingRequest = 0; + this.pending = {}; + this.invalid = {}; + this.reset(); + + var groups = ( this.groups = {} ), + rules; + $.each( this.settings.groups, function( key, value ) { + if ( typeof value === "string" ) { + value = value.split( /\s/ ); + } + $.each( value, function( index, name ) { + groups[ name ] = key; + } ); + } ); + rules = this.settings.rules; + $.each( rules, function( key, value ) { + rules[ key ] = $.validator.normalizeRule( value ); + } ); + + function delegate( event ) { + + // Set form expando on contenteditable + if ( !this.form && this.hasAttribute( "contenteditable" ) ) { + this.form = $( this ).closest( "form" )[ 0 ]; + this.name = $( this ).attr( "name" ); + } + + var validator = $.data( this.form, "validator" ), + eventType = "on" + event.type.replace( /^validate/, "" ), + settings = validator.settings; + if ( settings[ eventType ] && !$( this ).is( settings.ignore ) ) { + settings[ eventType ].call( validator, this, event ); + } + } + + $( this.currentForm ) + .on( "focusin.validate focusout.validate keyup.validate", + ":text, [type='password'], [type='file'], select, textarea, [type='number'], [type='search'], " + + "[type='tel'], [type='url'], [type='email'], [type='datetime'], [type='date'], [type='month'], " + + "[type='week'], [type='time'], [type='datetime-local'], [type='range'], [type='color'], " + + "[type='radio'], [type='checkbox'], [contenteditable], [type='button']", delegate ) + + // Support: Chrome, oldIE + // "select" is provided as event.target when clicking a option + .on( "click.validate", "select, option, [type='radio'], [type='checkbox']", delegate ); + + if ( this.settings.invalidHandler ) { + $( this.currentForm ).on( "invalid-form.validate", this.settings.invalidHandler ); + } + }, + + // https://jqueryvalidation.org/Validator.form/ + form: function() { + this.checkForm(); + $.extend( this.submitted, this.errorMap ); + this.invalid = $.extend( {}, this.errorMap ); + if ( !this.valid() ) { + $( this.currentForm ).triggerHandler( "invalid-form", [ this ] ); + } + this.showErrors(); + return this.valid(); + }, + + checkForm: function() { + this.prepareForm(); + for ( var i = 0, elements = ( this.currentElements = this.elements() ); elements[ i ]; i++ ) { + this.check( elements[ i ] ); + } + return this.valid(); + }, + + // https://jqueryvalidation.org/Validator.element/ + element: function( element ) { + var cleanElement = this.clean( element ), + checkElement = this.validationTargetFor( cleanElement ), + v = this, + result = true, + rs, group; + + if ( checkElement === undefined ) { + delete this.invalid[ cleanElement.name ]; + } else { + this.prepareElement( checkElement ); + this.currentElements = $( checkElement ); + + // If this element is grouped, then validate all group elements already + // containing a value + group = this.groups[ checkElement.name ]; + if ( group ) { + $.each( this.groups, function( name, testgroup ) { + if ( testgroup === group && name !== checkElement.name ) { + cleanElement = v.validationTargetFor( v.clean( v.findByName( name ) ) ); + if ( cleanElement && cleanElement.name in v.invalid ) { + v.currentElements.push( cleanElement ); + result = v.check( cleanElement ) && result; + } + } + } ); + } + + rs = this.check( checkElement ) !== false; + result = result && rs; + if ( rs ) { + this.invalid[ checkElement.name ] = false; + } else { + this.invalid[ checkElement.name ] = true; + } + + if ( !this.numberOfInvalids() ) { + + // Hide error containers on last error + this.toHide = this.toHide.add( this.containers ); + } + this.showErrors(); + + // Add aria-invalid status for screen readers + $( element ).attr( "aria-invalid", !rs ); + } + + return result; + }, + + // https://jqueryvalidation.org/Validator.showErrors/ + showErrors: function( errors ) { + if ( errors ) { + var validator = this; + + // Add items to error list and map + $.extend( this.errorMap, errors ); + this.errorList = $.map( this.errorMap, function( message, name ) { + return { + message: message, + element: validator.findByName( name )[ 0 ] + }; + } ); + + // Remove items from success list + this.successList = $.grep( this.successList, function( element ) { + return !( element.name in errors ); + } ); + } + if ( this.settings.showErrors ) { + this.settings.showErrors.call( this, this.errorMap, this.errorList ); + } else { + this.defaultShowErrors(); + } + }, + + // https://jqueryvalidation.org/Validator.resetForm/ + resetForm: function() { + if ( $.fn.resetForm ) { + $( this.currentForm ).resetForm(); + } + this.invalid = {}; + this.submitted = {}; + this.prepareForm(); + this.hideErrors(); + var elements = this.elements() + .removeData( "previousValue" ) + .removeAttr( "aria-invalid" ); + + this.resetElements( elements ); + }, + + resetElements: function( elements ) { + var i; + + if ( this.settings.unhighlight ) { + for ( i = 0; elements[ i ]; i++ ) { + this.settings.unhighlight.call( this, elements[ i ], + this.settings.errorClass, "" ); + this.findByName( elements[ i ].name ).removeClass( this.settings.validClass ); + } + } else { + elements + .removeClass( this.settings.errorClass ) + .removeClass( this.settings.validClass ); + } + }, + + numberOfInvalids: function() { + return this.objectLength( this.invalid ); + }, + + objectLength: function( obj ) { + /* jshint unused: false */ + var count = 0, + i; + for ( i in obj ) { + + // This check allows counting elements with empty error + // message as invalid elements + if ( obj[ i ] !== undefined && obj[ i ] !== null && obj[ i ] !== false ) { + count++; + } + } + return count; + }, + + hideErrors: function() { + this.hideThese( this.toHide ); + }, + + hideThese: function( errors ) { + errors.not( this.containers ).text( "" ); + this.addWrapper( errors ).hide(); + }, + + valid: function() { + return this.size() === 0; + }, + + size: function() { + return this.errorList.length; + }, + + focusInvalid: function() { + if ( this.settings.focusInvalid ) { + try { + $( this.findLastActive() || this.errorList.length && this.errorList[ 0 ].element || [] ) + .filter( ":visible" ) + .focus() + + // Manually trigger focusin event; without it, focusin handler isn't called, findLastActive won't have anything to find + .trigger( "focusin" ); + } catch ( e ) { + + // Ignore IE throwing errors when focusing hidden elements + } + } + }, + + findLastActive: function() { + var lastActive = this.lastActive; + return lastActive && $.grep( this.errorList, function( n ) { + return n.element.name === lastActive.name; + } ).length === 1 && lastActive; + }, + + elements: function() { + var validator = this, + rulesCache = {}; + + // Select all valid inputs inside the form (no submit or reset buttons) + return $( this.currentForm ) + .find( "input, select, textarea, [contenteditable]" ) + .not( ":submit, :reset, :image, :disabled" ) + .not( this.settings.ignore ) + .filter( function() { + var name = this.name || $( this ).attr( "name" ); // For contenteditable + if ( !name && validator.settings.debug && window.console ) { + console.error( "%o has no name assigned", this ); + } + + // Set form expando on contenteditable + if ( this.hasAttribute( "contenteditable" ) ) { + this.form = $( this ).closest( "form" )[ 0 ]; + this.name = name; + } + + // Select only the first element for each name, and only those with rules specified + if ( name in rulesCache || !validator.objectLength( $( this ).rules() ) ) { + return false; + } + + rulesCache[ name ] = true; + return true; + } ); + }, + + clean: function( selector ) { + return $( selector )[ 0 ]; + }, + + errors: function() { + var errorClass = this.settings.errorClass.split( " " ).join( "." ); + return $( this.settings.errorElement + "." + errorClass, this.errorContext ); + }, + + resetInternals: function() { + this.successList = []; + this.errorList = []; + this.errorMap = {}; + this.toShow = $( [] ); + this.toHide = $( [] ); + }, + + reset: function() { + this.resetInternals(); + this.currentElements = $( [] ); + }, + + prepareForm: function() { + this.reset(); + this.toHide = this.errors().add( this.containers ); + }, + + prepareElement: function( element ) { + this.reset(); + this.toHide = this.errorsFor( element ); + }, + + elementValue: function( element ) { + var $element = $( element ), + type = element.type, + val, idx; + + if ( type === "radio" || type === "checkbox" ) { + return this.findByName( element.name ).filter( ":checked" ).val(); + } else if ( type === "number" && typeof element.validity !== "undefined" ) { + return element.validity.badInput ? "NaN" : $element.val(); + } + + if ( element.hasAttribute( "contenteditable" ) ) { + val = $element.text(); + } else { + val = $element.val(); + } + + if ( type === "file" ) { + + // Modern browser (chrome & safari) + if ( val.substr( 0, 12 ) === "C:\\fakepath\\" ) { + return val.substr( 12 ); + } + + // Legacy browsers + // Unix-based path + idx = val.lastIndexOf( "/" ); + if ( idx >= 0 ) { + return val.substr( idx + 1 ); + } + + // Windows-based path + idx = val.lastIndexOf( "\\" ); + if ( idx >= 0 ) { + return val.substr( idx + 1 ); + } + + // Just the file name + return val; + } + + if ( typeof val === "string" ) { + return val.replace( /\r/g, "" ); + } + return val; + }, + + check: function( element ) { + element = this.validationTargetFor( this.clean( element ) ); + + var rules = $( element ).rules(), + rulesCount = $.map( rules, function( n, i ) { + return i; + } ).length, + dependencyMismatch = false, + val = this.elementValue( element ), + result, method, rule, normalizer; + + // Prioritize the local normalizer defined for this element over the global one + // if the former exists, otherwise user the global one in case it exists. + if ( typeof rules.normalizer === "function" ) { + normalizer = rules.normalizer; + } else if ( typeof this.settings.normalizer === "function" ) { + normalizer = this.settings.normalizer; + } + + // If normalizer is defined, then call it to retreive the changed value instead + // of using the real one. + // Note that `this` in the normalizer is `element`. + if ( normalizer ) { + val = normalizer.call( element, val ); + + if ( typeof val !== "string" ) { + throw new TypeError( "The normalizer should return a string value." ); + } + + // Delete the normalizer from rules to avoid treating it as a pre-defined method. + delete rules.normalizer; + } + + for ( method in rules ) { + rule = { method: method, parameters: rules[ method ] }; + try { + result = $.validator.methods[ method ].call( this, val, element, rule.parameters ); + + // If a method indicates that the field is optional and therefore valid, + // don't mark it as valid when there are no other rules + if ( result === "dependency-mismatch" && rulesCount === 1 ) { + dependencyMismatch = true; + continue; + } + dependencyMismatch = false; + + if ( result === "pending" ) { + this.toHide = this.toHide.not( this.errorsFor( element ) ); + return; + } + + if ( !result ) { + this.formatAndAdd( element, rule ); + return false; + } + } catch ( e ) { + if ( this.settings.debug && window.console ) { + console.log( "Exception occurred when checking element " + element.id + ", check the '" + rule.method + "' method.", e ); + } + if ( e instanceof TypeError ) { + e.message += ". Exception occurred when checking element " + element.id + ", check the '" + rule.method + "' method."; + } + + throw e; + } + } + if ( dependencyMismatch ) { + return; + } + if ( this.objectLength( rules ) ) { + this.successList.push( element ); + } + return true; + }, + + // Return the custom message for the given element and validation method + // specified in the element's HTML5 data attribute + // return the generic message if present and no method specific message is present + customDataMessage: function( element, method ) { + return $( element ).data( "msg" + method.charAt( 0 ).toUpperCase() + + method.substring( 1 ).toLowerCase() ) || $( element ).data( "msg" ); + }, + + // Return the custom message for the given element name and validation method + customMessage: function( name, method ) { + var m = this.settings.messages[ name ]; + return m && ( m.constructor === String ? m : m[ method ] ); + }, + + // Return the first defined argument, allowing empty strings + findDefined: function() { + for ( var i = 0; i < arguments.length; i++ ) { + if ( arguments[ i ] !== undefined ) { + return arguments[ i ]; + } + } + return undefined; + }, + + // The second parameter 'rule' used to be a string, and extended to an object literal + // of the following form: + // rule = { + // method: "method name", + // parameters: "the given method parameters" + // } + // + // The old behavior still supported, kept to maintain backward compatibility with + // old code, and will be removed in the next major release. + defaultMessage: function( element, rule ) { + if ( typeof rule === "string" ) { + rule = { method: rule }; + } + + var message = this.findDefined( + this.customMessage( element.name, rule.method ), + this.customDataMessage( element, rule.method ), + + // 'title' is never undefined, so handle empty string as undefined + !this.settings.ignoreTitle && element.title || undefined, + $.validator.messages[ rule.method ], + "Warning: No message defined for " + element.name + "" + ), + theregex = /\$?\{(\d+)\}/g; + if ( typeof message === "function" ) { + message = message.call( this, rule.parameters, element ); + } else if ( theregex.test( message ) ) { + message = $.validator.format( message.replace( theregex, "{$1}" ), rule.parameters ); + } + + return message; + }, + + formatAndAdd: function( element, rule ) { + var message = this.defaultMessage( element, rule ); + + this.errorList.push( { + message: message, + element: element, + method: rule.method + } ); + + this.errorMap[ element.name ] = message; + this.submitted[ element.name ] = message; + }, + + addWrapper: function( toToggle ) { + if ( this.settings.wrapper ) { + toToggle = toToggle.add( toToggle.parent( this.settings.wrapper ) ); + } + return toToggle; + }, + + defaultShowErrors: function() { + var i, elements, error; + for ( i = 0; this.errorList[ i ]; i++ ) { + error = this.errorList[ i ]; + if ( this.settings.highlight ) { + this.settings.highlight.call( this, error.element, this.settings.errorClass, this.settings.validClass ); + } + this.showLabel( error.element, error.message ); + } + if ( this.errorList.length ) { + this.toShow = this.toShow.add( this.containers ); + } + if ( this.settings.success ) { + for ( i = 0; this.successList[ i ]; i++ ) { + this.showLabel( this.successList[ i ] ); + } + } + if ( this.settings.unhighlight ) { + for ( i = 0, elements = this.validElements(); elements[ i ]; i++ ) { + this.settings.unhighlight.call( this, elements[ i ], this.settings.errorClass, this.settings.validClass ); + } + } + this.toHide = this.toHide.not( this.toShow ); + this.hideErrors(); + this.addWrapper( this.toShow ).show(); + }, + + validElements: function() { + return this.currentElements.not( this.invalidElements() ); + }, + + invalidElements: function() { + return $( this.errorList ).map( function() { + return this.element; + } ); + }, + + showLabel: function( element, message ) { + var place, group, errorID, v, + error = this.errorsFor( element ), + elementID = this.idOrName( element ), + describedBy = $( element ).attr( "aria-describedby" ); + + if ( error.length ) { + + // Refresh error/success class + error.removeClass( this.settings.validClass ).addClass( this.settings.errorClass ); + + // Replace message on existing label + error.html( message ); + } else { + + // Create error element + error = $( "<" + this.settings.errorElement + ">" ) + .attr( "id", elementID + "-error" ) + .addClass( this.settings.errorClass ) + .html( message || "" ); + + // Maintain reference to the element to be placed into the DOM + place = error; + if ( this.settings.wrapper ) { + + // Make sure the element is visible, even in IE + // actually showing the wrapped element is handled elsewhere + place = error.hide().show().wrap( "<" + this.settings.wrapper + "/>" ).parent(); + } + if ( this.labelContainer.length ) { + this.labelContainer.append( place ); + } else if ( this.settings.errorPlacement ) { + this.settings.errorPlacement.call( this, place, $( element ) ); + } else { + place.insertAfter( element ); + } + + // Link error back to the element + if ( error.is( "label" ) ) { + + // If the error is a label, then associate using 'for' + error.attr( "for", elementID ); + + // If the element is not a child of an associated label, then it's necessary + // to explicitly apply aria-describedby + } else if ( error.parents( "label[for='" + this.escapeCssMeta( elementID ) + "']" ).length === 0 ) { + errorID = error.attr( "id" ); + + // Respect existing non-error aria-describedby + if ( !describedBy ) { + describedBy = errorID; + } else if ( !describedBy.match( new RegExp( "\\b" + this.escapeCssMeta( errorID ) + "\\b" ) ) ) { + + // Add to end of list if not already present + describedBy += " " + errorID; + } + $( element ).attr( "aria-describedby", describedBy ); + + // If this element is grouped, then assign to all elements in the same group + group = this.groups[ element.name ]; + if ( group ) { + v = this; + $.each( v.groups, function( name, testgroup ) { + if ( testgroup === group ) { + $( "[name='" + v.escapeCssMeta( name ) + "']", v.currentForm ) + .attr( "aria-describedby", error.attr( "id" ) ); + } + } ); + } + } + } + if ( !message && this.settings.success ) { + error.text( "" ); + if ( typeof this.settings.success === "string" ) { + error.addClass( this.settings.success ); + } else { + this.settings.success( error, element ); + } + } + this.toShow = this.toShow.add( error ); + }, + + errorsFor: function( element ) { + var name = this.escapeCssMeta( this.idOrName( element ) ), + describer = $( element ).attr( "aria-describedby" ), + selector = "label[for='" + name + "'], label[for='" + name + "'] *"; + + // 'aria-describedby' should directly reference the error element + if ( describer ) { + selector = selector + ", #" + this.escapeCssMeta( describer ) + .replace( /\s+/g, ", #" ); + } + + return this + .errors() + .filter( selector ); + }, + + // See https://api.jquery.com/category/selectors/, for CSS + // meta-characters that should be escaped in order to be used with JQuery + // as a literal part of a name/id or any selector. + escapeCssMeta: function( string ) { + return string.replace( /([\\!"#$%&'()*+,./:;<=>?@\[\]^`{|}~])/g, "\\$1" ); + }, + + idOrName: function( element ) { + return this.groups[ element.name ] || ( this.checkable( element ) ? element.name : element.id || element.name ); + }, + + validationTargetFor: function( element ) { + + // If radio/checkbox, validate first element in group instead + if ( this.checkable( element ) ) { + element = this.findByName( element.name ); + } + + // Always apply ignore filter + return $( element ).not( this.settings.ignore )[ 0 ]; + }, + + checkable: function( element ) { + return ( /radio|checkbox/i ).test( element.type ); + }, + + findByName: function( name ) { + return $( this.currentForm ).find( "[name='" + this.escapeCssMeta( name ) + "']" ); + }, + + getLength: function( value, element ) { + switch ( element.nodeName.toLowerCase() ) { + case "select": + return $( "option:selected", element ).length; + case "input": + if ( this.checkable( element ) ) { + return this.findByName( element.name ).filter( ":checked" ).length; + } + } + return value.length; + }, + + depend: function( param, element ) { + return this.dependTypes[ typeof param ] ? this.dependTypes[ typeof param ]( param, element ) : true; + }, + + dependTypes: { + "boolean": function( param ) { + return param; + }, + "string": function( param, element ) { + return !!$( param, element.form ).length; + }, + "function": function( param, element ) { + return param( element ); + } + }, + + optional: function( element ) { + var val = this.elementValue( element ); + return !$.validator.methods.required.call( this, val, element ) && "dependency-mismatch"; + }, + + startRequest: function( element ) { + if ( !this.pending[ element.name ] ) { + this.pendingRequest++; + $( element ).addClass( this.settings.pendingClass ); + this.pending[ element.name ] = true; + } + }, + + stopRequest: function( element, valid ) { + this.pendingRequest--; + + // Sometimes synchronization fails, make sure pendingRequest is never < 0 + if ( this.pendingRequest < 0 ) { + this.pendingRequest = 0; + } + delete this.pending[ element.name ]; + $( element ).removeClass( this.settings.pendingClass ); + if ( valid && this.pendingRequest === 0 && this.formSubmitted && this.form() ) { + $( this.currentForm ).submit(); + + // Remove the hidden input that was used as a replacement for the + // missing submit button. The hidden input is added by `handle()` + // to ensure that the value of the used submit button is passed on + // for scripted submits triggered by this method + if ( this.submitButton ) { + $( "input:hidden[name='" + this.submitButton.name + "']", this.currentForm ).remove(); + } + + this.formSubmitted = false; + } else if ( !valid && this.pendingRequest === 0 && this.formSubmitted ) { + $( this.currentForm ).triggerHandler( "invalid-form", [ this ] ); + this.formSubmitted = false; + } + }, + + previousValue: function( element, method ) { + method = typeof method === "string" && method || "remote"; + + return $.data( element, "previousValue" ) || $.data( element, "previousValue", { + old: null, + valid: true, + message: this.defaultMessage( element, { method: method } ) + } ); + }, + + // Cleans up all forms and elements, removes validator-specific events + destroy: function() { + this.resetForm(); + + $( this.currentForm ) + .off( ".validate" ) + .removeData( "validator" ) + .find( ".validate-equalTo-blur" ) + .off( ".validate-equalTo" ) + .removeClass( "validate-equalTo-blur" ); + } + + }, + + classRuleSettings: { + required: { required: true }, + email: { email: true }, + url: { url: true }, + date: { date: true }, + dateISO: { dateISO: true }, + number: { number: true }, + digits: { digits: true }, + creditcard: { creditcard: true } + }, + + addClassRules: function( className, rules ) { + if ( className.constructor === String ) { + this.classRuleSettings[ className ] = rules; + } else { + $.extend( this.classRuleSettings, className ); + } + }, + + classRules: function( element ) { + var rules = {}, + classes = $( element ).attr( "class" ); + + if ( classes ) { + $.each( classes.split( " " ), function() { + if ( this in $.validator.classRuleSettings ) { + $.extend( rules, $.validator.classRuleSettings[ this ] ); + } + } ); + } + return rules; + }, + + normalizeAttributeRule: function( rules, type, method, value ) { + + // Convert the value to a number for number inputs, and for text for backwards compability + // allows type="date" and others to be compared as strings + if ( /min|max|step/.test( method ) && ( type === null || /number|range|text/.test( type ) ) ) { + value = Number( value ); + + // Support Opera Mini, which returns NaN for undefined minlength + if ( isNaN( value ) ) { + value = undefined; + } + } + + if ( value || value === 0 ) { + rules[ method ] = value; + } else if ( type === method && type !== "range" ) { + + // Exception: the jquery validate 'range' method + // does not test for the html5 'range' type + rules[ method ] = true; + } + }, + + attributeRules: function( element ) { + var rules = {}, + $element = $( element ), + type = element.getAttribute( "type" ), + method, value; + + for ( method in $.validator.methods ) { + + // Support for in both html5 and older browsers + if ( method === "required" ) { + value = element.getAttribute( method ); + + // Some browsers return an empty string for the required attribute + // and non-HTML5 browsers might have required="" markup + if ( value === "" ) { + value = true; + } + + // Force non-HTML5 browsers to return bool + value = !!value; + } else { + value = $element.attr( method ); + } + + this.normalizeAttributeRule( rules, type, method, value ); + } + + // 'maxlength' may be returned as -1, 2147483647 ( IE ) and 524288 ( safari ) for text inputs + if ( rules.maxlength && /-1|2147483647|524288/.test( rules.maxlength ) ) { + delete rules.maxlength; + } + + return rules; + }, + + dataRules: function( element ) { + var rules = {}, + $element = $( element ), + type = element.getAttribute( "type" ), + method, value; + + for ( method in $.validator.methods ) { + value = $element.data( "rule" + method.charAt( 0 ).toUpperCase() + method.substring( 1 ).toLowerCase() ); + this.normalizeAttributeRule( rules, type, method, value ); + } + return rules; + }, + + staticRules: function( element ) { + var rules = {}, + validator = $.data( element.form, "validator" ); + + if ( validator.settings.rules ) { + rules = $.validator.normalizeRule( validator.settings.rules[ element.name ] ) || {}; + } + return rules; + }, + + normalizeRules: function( rules, element ) { + + // Handle dependency check + $.each( rules, function( prop, val ) { + + // Ignore rule when param is explicitly false, eg. required:false + if ( val === false ) { + delete rules[ prop ]; + return; + } + if ( val.param || val.depends ) { + var keepRule = true; + switch ( typeof val.depends ) { + case "string": + keepRule = !!$( val.depends, element.form ).length; + break; + case "function": + keepRule = val.depends.call( element, element ); + break; + } + if ( keepRule ) { + rules[ prop ] = val.param !== undefined ? val.param : true; + } else { + $.data( element.form, "validator" ).resetElements( $( element ) ); + delete rules[ prop ]; + } + } + } ); + + // Evaluate parameters + $.each( rules, function( rule, parameter ) { + rules[ rule ] = $.isFunction( parameter ) && rule !== "normalizer" ? parameter( element ) : parameter; + } ); + + // Clean number parameters + $.each( [ "minlength", "maxlength" ], function() { + if ( rules[ this ] ) { + rules[ this ] = Number( rules[ this ] ); + } + } ); + $.each( [ "rangelength", "range" ], function() { + var parts; + if ( rules[ this ] ) { + if ( $.isArray( rules[ this ] ) ) { + rules[ this ] = [ Number( rules[ this ][ 0 ] ), Number( rules[ this ][ 1 ] ) ]; + } else if ( typeof rules[ this ] === "string" ) { + parts = rules[ this ].replace( /[\[\]]/g, "" ).split( /[\s,]+/ ); + rules[ this ] = [ Number( parts[ 0 ] ), Number( parts[ 1 ] ) ]; + } + } + } ); + + if ( $.validator.autoCreateRanges ) { + + // Auto-create ranges + if ( rules.min != null && rules.max != null ) { + rules.range = [ rules.min, rules.max ]; + delete rules.min; + delete rules.max; + } + if ( rules.minlength != null && rules.maxlength != null ) { + rules.rangelength = [ rules.minlength, rules.maxlength ]; + delete rules.minlength; + delete rules.maxlength; + } + } + + return rules; + }, + + // Converts a simple string to a {string: true} rule, e.g., "required" to {required:true} + normalizeRule: function( data ) { + if ( typeof data === "string" ) { + var transformed = {}; + $.each( data.split( /\s/ ), function() { + transformed[ this ] = true; + } ); + data = transformed; + } + return data; + }, + + // https://jqueryvalidation.org/jQuery.validator.addMethod/ + addMethod: function( name, method, message ) { + $.validator.methods[ name ] = method; + $.validator.messages[ name ] = message !== undefined ? message : $.validator.messages[ name ]; + if ( method.length < 3 ) { + $.validator.addClassRules( name, $.validator.normalizeRule( name ) ); + } + }, + + // https://jqueryvalidation.org/jQuery.validator.methods/ + methods: { + + // https://jqueryvalidation.org/required-method/ + required: function( value, element, param ) { + + // Check if dependency is met + if ( !this.depend( param, element ) ) { + return "dependency-mismatch"; + } + if ( element.nodeName.toLowerCase() === "select" ) { + + // Could be an array for select-multiple or a string, both are fine this way + var val = $( element ).val(); + return val && val.length > 0; + } + if ( this.checkable( element ) ) { + return this.getLength( value, element ) > 0; + } + return value.length > 0; + }, + + // https://jqueryvalidation.org/email-method/ + email: function( value, element ) { + + // From https://html.spec.whatwg.org/multipage/forms.html#valid-e-mail-address + // Retrieved 2014-01-14 + // If you have a problem with this implementation, report a bug against the above spec + // Or use custom methods to implement your own email validation + return this.optional( element ) || /^[a-zA-Z0-9.!#$%&'*+\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/.test( value ); + }, + + // https://jqueryvalidation.org/url-method/ + url: function( value, element ) { + + // Copyright (c) 2010-2013 Diego Perini, MIT licensed + // https://gist.github.com/dperini/729294 + // see also https://mathiasbynens.be/demo/url-regex + // modified to allow protocol-relative URLs + return this.optional( element ) || /^(?:(?:(?:https?|ftp):)?\/\/)(?:\S+(?::\S*)?@)?(?:(?!(?:10|127)(?:\.\d{1,3}){3})(?!(?:169\.254|192\.168)(?:\.\d{1,3}){2})(?!172\.(?:1[6-9]|2\d|3[0-1])(?:\.\d{1,3}){2})(?:[1-9]\d?|1\d\d|2[01]\d|22[0-3])(?:\.(?:1?\d{1,2}|2[0-4]\d|25[0-5])){2}(?:\.(?:[1-9]\d?|1\d\d|2[0-4]\d|25[0-4]))|(?:(?:[a-z\u00a1-\uffff0-9]-*)*[a-z\u00a1-\uffff0-9]+)(?:\.(?:[a-z\u00a1-\uffff0-9]-*)*[a-z\u00a1-\uffff0-9]+)*(?:\.(?:[a-z\u00a1-\uffff]{2,})).?)(?::\d{2,5})?(?:[/?#]\S*)?$/i.test( value ); + }, + + // https://jqueryvalidation.org/date-method/ + date: function( value, element ) { + return this.optional( element ) || !/Invalid|NaN/.test( new Date( value ).toString() ); + }, + + // https://jqueryvalidation.org/dateISO-method/ + dateISO: function( value, element ) { + return this.optional( element ) || /^\d{4}[\/\-](0?[1-9]|1[012])[\/\-](0?[1-9]|[12][0-9]|3[01])$/.test( value ); + }, + + // https://jqueryvalidation.org/number-method/ + number: function( value, element ) { + return this.optional( element ) || /^(?:-?\d+|-?\d{1,3}(?:,\d{3})+)?(?:\.\d+)?$/.test( value ); + }, + + // https://jqueryvalidation.org/digits-method/ + digits: function( value, element ) { + return this.optional( element ) || /^\d+$/.test( value ); + }, + + // https://jqueryvalidation.org/minlength-method/ + minlength: function( value, element, param ) { + var length = $.isArray( value ) ? value.length : this.getLength( value, element ); + return this.optional( element ) || length >= param; + }, + + // https://jqueryvalidation.org/maxlength-method/ + maxlength: function( value, element, param ) { + var length = $.isArray( value ) ? value.length : this.getLength( value, element ); + return this.optional( element ) || length <= param; + }, + + // https://jqueryvalidation.org/rangelength-method/ + rangelength: function( value, element, param ) { + var length = $.isArray( value ) ? value.length : this.getLength( value, element ); + return this.optional( element ) || ( length >= param[ 0 ] && length <= param[ 1 ] ); + }, + + // https://jqueryvalidation.org/min-method/ + min: function( value, element, param ) { + return this.optional( element ) || value >= param; + }, + + // https://jqueryvalidation.org/max-method/ + max: function( value, element, param ) { + return this.optional( element ) || value <= param; + }, + + // https://jqueryvalidation.org/range-method/ + range: function( value, element, param ) { + return this.optional( element ) || ( value >= param[ 0 ] && value <= param[ 1 ] ); + }, + + // https://jqueryvalidation.org/step-method/ + step: function( value, element, param ) { + var type = $( element ).attr( "type" ), + errorMessage = "Step attribute on input type " + type + " is not supported.", + supportedTypes = [ "text", "number", "range" ], + re = new RegExp( "\\b" + type + "\\b" ), + notSupported = type && !re.test( supportedTypes.join() ), + decimalPlaces = function( num ) { + var match = ( "" + num ).match( /(?:\.(\d+))?$/ ); + if ( !match ) { + return 0; + } + + // Number of digits right of decimal point. + return match[ 1 ] ? match[ 1 ].length : 0; + }, + toInt = function( num ) { + return Math.round( num * Math.pow( 10, decimals ) ); + }, + valid = true, + decimals; + + // Works only for text, number and range input types + // TODO find a way to support input types date, datetime, datetime-local, month, time and week + if ( notSupported ) { + throw new Error( errorMessage ); + } + + decimals = decimalPlaces( param ); + + // Value can't have too many decimals + if ( decimalPlaces( value ) > decimals || toInt( value ) % toInt( param ) !== 0 ) { + valid = false; + } + + return this.optional( element ) || valid; + }, + + // https://jqueryvalidation.org/equalTo-method/ + equalTo: function( value, element, param ) { + + // Bind to the blur event of the target in order to revalidate whenever the target field is updated + var target = $( param ); + if ( this.settings.onfocusout && target.not( ".validate-equalTo-blur" ).length ) { + target.addClass( "validate-equalTo-blur" ).on( "blur.validate-equalTo", function() { + $( element ).valid(); + } ); + } + return value === target.val(); + }, + + // https://jqueryvalidation.org/remote-method/ + remote: function( value, element, param, method ) { + if ( this.optional( element ) ) { + return "dependency-mismatch"; + } + + method = typeof method === "string" && method || "remote"; + + var previous = this.previousValue( element, method ), + validator, data, optionDataString; + + if ( !this.settings.messages[ element.name ] ) { + this.settings.messages[ element.name ] = {}; + } + previous.originalMessage = previous.originalMessage || this.settings.messages[ element.name ][ method ]; + this.settings.messages[ element.name ][ method ] = previous.message; + + param = typeof param === "string" && { url: param } || param; + optionDataString = $.param( $.extend( { data: value }, param.data ) ); + if ( previous.old === optionDataString ) { + return previous.valid; + } + + previous.old = optionDataString; + validator = this; + this.startRequest( element ); + data = {}; + data[ element.name ] = value; + $.ajax( $.extend( true, { + mode: "abort", + port: "validate" + element.name, + dataType: "json", + data: data, + context: validator.currentForm, + success: function( response ) { + var valid = response === true || response === "true", + errors, message, submitted; + + validator.settings.messages[ element.name ][ method ] = previous.originalMessage; + if ( valid ) { + submitted = validator.formSubmitted; + validator.resetInternals(); + validator.toHide = validator.errorsFor( element ); + validator.formSubmitted = submitted; + validator.successList.push( element ); + validator.invalid[ element.name ] = false; + validator.showErrors(); + } else { + errors = {}; + message = response || validator.defaultMessage( element, { method: method, parameters: value } ); + errors[ element.name ] = previous.message = message; + validator.invalid[ element.name ] = true; + validator.showErrors( errors ); + } + previous.valid = valid; + validator.stopRequest( element, valid ); + } + }, param ) ); + return "pending"; + } + } + +} ); + +// Ajax mode: abort +// usage: $.ajax({ mode: "abort"[, port: "uniqueport"]}); +// if mode:"abort" is used, the previous request on that port (port can be undefined) is aborted via XMLHttpRequest.abort() + +var pendingRequests = {}, + ajax; + +// Use a prefilter if available (1.5+) +if ( $.ajaxPrefilter ) { + $.ajaxPrefilter( function( settings, _, xhr ) { + var port = settings.port; + if ( settings.mode === "abort" ) { + if ( pendingRequests[ port ] ) { + pendingRequests[ port ].abort(); + } + pendingRequests[ port ] = xhr; + } + } ); +} else { + + // Proxy ajax + ajax = $.ajax; + $.ajax = function( settings ) { + var mode = ( "mode" in settings ? settings : $.ajaxSettings ).mode, + port = ( "port" in settings ? settings : $.ajaxSettings ).port; + if ( mode === "abort" ) { + if ( pendingRequests[ port ] ) { + pendingRequests[ port ].abort(); + } + pendingRequests[ port ] = ajax.apply( this, arguments ); + return pendingRequests[ port ]; + } + return ajax.apply( this, arguments ); + }; +} +return $; +})); \ No newline at end of file diff --git a/MyApp.Client/public/lib/jquery.validate.unobtrusive.js b/MyApp.Client/public/lib/jquery.validate.unobtrusive.js new file mode 100644 index 0000000..73f5298 --- /dev/null +++ b/MyApp.Client/public/lib/jquery.validate.unobtrusive.js @@ -0,0 +1,432 @@ +// Unobtrusive validation support library for jQuery and jQuery Validate +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. +// @version v3.2.11 + +/*jslint white: true, browser: true, onevar: true, undef: true, nomen: true, eqeqeq: true, plusplus: true, bitwise: true, regexp: true, newcap: true, immed: true, strict: false */ +/*global document: false, jQuery: false */ + +(function (factory) { + if (typeof define === 'function' && define.amd) { + // AMD. Register as an anonymous module. + define("jquery.validate.unobtrusive", ['jquery-validation'], factory); + } else if (typeof module === 'object' && module.exports) { + // CommonJS-like environments that support module.exports + module.exports = factory(require('jquery-validation')); + } else { + // Browser global + jQuery.validator.unobtrusive = factory(jQuery); + } +}(function ($) { + var $jQval = $.validator, + adapters, + data_validation = "unobtrusiveValidation"; + + function setValidationValues(options, ruleName, value) { + options.rules[ruleName] = value; + if (options.message) { + options.messages[ruleName] = options.message; + } + } + + function splitAndTrim(value) { + return value.replace(/^\s+|\s+$/g, "").split(/\s*,\s*/g); + } + + function escapeAttributeValue(value) { + // As mentioned on http://api.jquery.com/category/selectors/ + return value.replace(/([!"#$%&'()*+,./:;<=>?@\[\\\]^`{|}~])/g, "\\$1"); + } + + function getModelPrefix(fieldName) { + return fieldName.substr(0, fieldName.lastIndexOf(".") + 1); + } + + function appendModelPrefix(value, prefix) { + if (value.indexOf("*.") === 0) { + value = value.replace("*.", prefix); + } + return value; + } + + function onError(error, inputElement) { // 'this' is the form element + var container = $(this).find("[data-valmsg-for='" + escapeAttributeValue(inputElement[0].name) + "']"), + replaceAttrValue = container.attr("data-valmsg-replace"), + replace = replaceAttrValue ? $.parseJSON(replaceAttrValue) !== false : null; + + container.removeClass("field-validation-valid").addClass("field-validation-error"); + error.data("unobtrusiveContainer", container); + + if (replace) { + container.empty(); + error.removeClass("input-validation-error").appendTo(container); + } + else { + error.hide(); + } + } + + function onErrors(event, validator) { // 'this' is the form element + var container = $(this).find("[data-valmsg-summary=true]"), + list = container.find("ul"); + + if (list && list.length && validator.errorList.length) { + list.empty(); + container.addClass("validation-summary-errors").removeClass("validation-summary-valid"); + + $.each(validator.errorList, function () { + $("
  • ").html(this.message).appendTo(list); + }); + } + } + + function onSuccess(error) { // 'this' is the form element + var container = error.data("unobtrusiveContainer"); + + if (container) { + var replaceAttrValue = container.attr("data-valmsg-replace"), + replace = replaceAttrValue ? $.parseJSON(replaceAttrValue) : null; + + container.addClass("field-validation-valid").removeClass("field-validation-error"); + error.removeData("unobtrusiveContainer"); + + if (replace) { + container.empty(); + } + } + } + + function onReset(event) { // 'this' is the form element + var $form = $(this), + key = '__jquery_unobtrusive_validation_form_reset'; + if ($form.data(key)) { + return; + } + // Set a flag that indicates we're currently resetting the form. + $form.data(key, true); + try { + $form.data("validator").resetForm(); + } finally { + $form.removeData(key); + } + + $form.find(".validation-summary-errors") + .addClass("validation-summary-valid") + .removeClass("validation-summary-errors"); + $form.find(".field-validation-error") + .addClass("field-validation-valid") + .removeClass("field-validation-error") + .removeData("unobtrusiveContainer") + .find(">*") // If we were using valmsg-replace, get the underlying error + .removeData("unobtrusiveContainer"); + } + + function validationInfo(form) { + var $form = $(form), + result = $form.data(data_validation), + onResetProxy = $.proxy(onReset, form), + defaultOptions = $jQval.unobtrusive.options || {}, + execInContext = function (name, args) { + var func = defaultOptions[name]; + func && $.isFunction(func) && func.apply(form, args); + }; + + if (!result) { + result = { + options: { // options structure passed to jQuery Validate's validate() method + errorClass: defaultOptions.errorClass || "input-validation-error", + errorElement: defaultOptions.errorElement || "span", + errorPlacement: function () { + onError.apply(form, arguments); + execInContext("errorPlacement", arguments); + }, + invalidHandler: function () { + onErrors.apply(form, arguments); + execInContext("invalidHandler", arguments); + }, + messages: {}, + rules: {}, + success: function () { + onSuccess.apply(form, arguments); + execInContext("success", arguments); + } + }, + attachValidation: function () { + $form + .off("reset." + data_validation, onResetProxy) + .on("reset." + data_validation, onResetProxy) + .validate(this.options); + }, + validate: function () { // a validation function that is called by unobtrusive Ajax + $form.validate(); + return $form.valid(); + } + }; + $form.data(data_validation, result); + } + + return result; + } + + $jQval.unobtrusive = { + adapters: [], + + parseElement: function (element, skipAttach) { + /// + /// Parses a single HTML element for unobtrusive validation attributes. + /// + /// The HTML element to be parsed. + /// [Optional] true to skip attaching the + /// validation to the form. If parsing just this single element, you should specify true. + /// If parsing several elements, you should specify false, and manually attach the validation + /// to the form when you are finished. The default is false. + var $element = $(element), + form = $element.parents("form")[0], + valInfo, rules, messages; + + if (!form) { // Cannot do client-side validation without a form + return; + } + + valInfo = validationInfo(form); + valInfo.options.rules[element.name] = rules = {}; + valInfo.options.messages[element.name] = messages = {}; + + $.each(this.adapters, function () { + var prefix = "data-val-" + this.name, + message = $element.attr(prefix), + paramValues = {}; + + if (message !== undefined) { // Compare against undefined, because an empty message is legal (and falsy) + prefix += "-"; + + $.each(this.params, function () { + paramValues[this] = $element.attr(prefix + this); + }); + + this.adapt({ + element: element, + form: form, + message: message, + params: paramValues, + rules: rules, + messages: messages + }); + } + }); + + $.extend(rules, { "__dummy__": true }); + + if (!skipAttach) { + valInfo.attachValidation(); + } + }, + + parse: function (selector) { + /// + /// Parses all the HTML elements in the specified selector. It looks for input elements decorated + /// with the [data-val=true] attribute value and enables validation according to the data-val-* + /// attribute values. + /// + /// Any valid jQuery selector. + + // $forms includes all forms in selector's DOM hierarchy (parent, children and self) that have at least one + // element with data-val=true + var $selector = $(selector), + $forms = $selector.parents() + .addBack() + .filter("form") + .add($selector.find("form")) + .has("[data-val=true]"); + + $selector.find("[data-val=true]").each(function () { + $jQval.unobtrusive.parseElement(this, true); + }); + + $forms.each(function () { + var info = validationInfo(this); + if (info) { + info.attachValidation(); + } + }); + } + }; + + adapters = $jQval.unobtrusive.adapters; + + adapters.add = function (adapterName, params, fn) { + /// Adds a new adapter to convert unobtrusive HTML into a jQuery Validate validation. + /// The name of the adapter to be added. This matches the name used + /// in the data-val-nnnn HTML attribute (where nnnn is the adapter name). + /// [Optional] An array of parameter names (strings) that will + /// be extracted from the data-val-nnnn-mmmm HTML attributes (where nnnn is the adapter name, and + /// mmmm is the parameter name). + /// The function to call, which adapts the values from the HTML + /// attributes into jQuery Validate rules and/or messages. + /// + if (!fn) { // Called with no params, just a function + fn = params; + params = []; + } + this.push({ name: adapterName, params: params, adapt: fn }); + return this; + }; + + adapters.addBool = function (adapterName, ruleName) { + /// Adds a new adapter to convert unobtrusive HTML into a jQuery Validate validation, where + /// the jQuery Validate validation rule has no parameter values. + /// The name of the adapter to be added. This matches the name used + /// in the data-val-nnnn HTML attribute (where nnnn is the adapter name). + /// [Optional] The name of the jQuery Validate rule. If not provided, the value + /// of adapterName will be used instead. + /// + return this.add(adapterName, function (options) { + setValidationValues(options, ruleName || adapterName, true); + }); + }; + + adapters.addMinMax = function (adapterName, minRuleName, maxRuleName, minMaxRuleName, minAttribute, maxAttribute) { + /// Adds a new adapter to convert unobtrusive HTML into a jQuery Validate validation, where + /// the jQuery Validate validation has three potential rules (one for min-only, one for max-only, and + /// one for min-and-max). The HTML parameters are expected to be named -min and -max. + /// The name of the adapter to be added. This matches the name used + /// in the data-val-nnnn HTML attribute (where nnnn is the adapter name). + /// The name of the jQuery Validate rule to be used when you only + /// have a minimum value. + /// The name of the jQuery Validate rule to be used when you only + /// have a maximum value. + /// The name of the jQuery Validate rule to be used when you + /// have both a minimum and maximum value. + /// [Optional] The name of the HTML attribute that + /// contains the minimum value. The default is "min". + /// [Optional] The name of the HTML attribute that + /// contains the maximum value. The default is "max". + /// + return this.add(adapterName, [minAttribute || "min", maxAttribute || "max"], function (options) { + var min = options.params.min, + max = options.params.max; + + if (min && max) { + setValidationValues(options, minMaxRuleName, [min, max]); + } + else if (min) { + setValidationValues(options, minRuleName, min); + } + else if (max) { + setValidationValues(options, maxRuleName, max); + } + }); + }; + + adapters.addSingleVal = function (adapterName, attribute, ruleName) { + /// Adds a new adapter to convert unobtrusive HTML into a jQuery Validate validation, where + /// the jQuery Validate validation rule has a single value. + /// The name of the adapter to be added. This matches the name used + /// in the data-val-nnnn HTML attribute(where nnnn is the adapter name). + /// [Optional] The name of the HTML attribute that contains the value. + /// The default is "val". + /// [Optional] The name of the jQuery Validate rule. If not provided, the value + /// of adapterName will be used instead. + /// + return this.add(adapterName, [attribute || "val"], function (options) { + setValidationValues(options, ruleName || adapterName, options.params[attribute]); + }); + }; + + $jQval.addMethod("__dummy__", function (value, element, params) { + return true; + }); + + $jQval.addMethod("regex", function (value, element, params) { + var match; + if (this.optional(element)) { + return true; + } + + match = new RegExp(params).exec(value); + return (match && (match.index === 0) && (match[0].length === value.length)); + }); + + $jQval.addMethod("nonalphamin", function (value, element, nonalphamin) { + var match; + if (nonalphamin) { + match = value.match(/\W/g); + match = match && match.length >= nonalphamin; + } + return match; + }); + + if ($jQval.methods.extension) { + adapters.addSingleVal("accept", "mimtype"); + adapters.addSingleVal("extension", "extension"); + } else { + // for backward compatibility, when the 'extension' validation method does not exist, such as with versions + // of JQuery Validation plugin prior to 1.10, we should use the 'accept' method for + // validating the extension, and ignore mime-type validations as they are not supported. + adapters.addSingleVal("extension", "extension", "accept"); + } + + adapters.addSingleVal("regex", "pattern"); + adapters.addBool("creditcard").addBool("date").addBool("digits").addBool("email").addBool("number").addBool("url"); + adapters.addMinMax("length", "minlength", "maxlength", "rangelength").addMinMax("range", "min", "max", "range"); + adapters.addMinMax("minlength", "minlength").addMinMax("maxlength", "minlength", "maxlength"); + adapters.add("equalto", ["other"], function (options) { + var prefix = getModelPrefix(options.element.name), + other = options.params.other, + fullOtherName = appendModelPrefix(other, prefix), + element = $(options.form).find(":input").filter("[name='" + escapeAttributeValue(fullOtherName) + "']")[0]; + + setValidationValues(options, "equalTo", element); + }); + adapters.add("required", function (options) { + // jQuery Validate equates "required" with "mandatory" for checkbox elements + if (options.element.tagName.toUpperCase() !== "INPUT" || options.element.type.toUpperCase() !== "CHECKBOX") { + setValidationValues(options, "required", true); + } + }); + adapters.add("remote", ["url", "type", "additionalfields"], function (options) { + var value = { + url: options.params.url, + type: options.params.type || "GET", + data: {} + }, + prefix = getModelPrefix(options.element.name); + + $.each(splitAndTrim(options.params.additionalfields || options.element.name), function (i, fieldName) { + var paramName = appendModelPrefix(fieldName, prefix); + value.data[paramName] = function () { + var field = $(options.form).find(":input").filter("[name='" + escapeAttributeValue(paramName) + "']"); + // For checkboxes and radio buttons, only pick up values from checked fields. + if (field.is(":checkbox")) { + return field.filter(":checked").val() || field.filter(":hidden").val() || ''; + } + else if (field.is(":radio")) { + return field.filter(":checked").val() || ''; + } + return field.val(); + }; + }); + + setValidationValues(options, "remote", value); + }); + adapters.add("password", ["min", "nonalphamin", "regex"], function (options) { + if (options.params.min) { + setValidationValues(options, "minlength", options.params.min); + } + if (options.params.nonalphamin) { + setValidationValues(options, "nonalphamin", options.params.nonalphamin); + } + if (options.params.regex) { + setValidationValues(options, "regex", options.params.regex); + } + }); + adapters.add("fileextensions", ["extensions"], function (options) { + setValidationValues(options, "extension", options.params.extensions); + }); + + $(function () { + $jQval.unobtrusive.parse(document); + }); + + return $jQval.unobtrusive; +})); diff --git a/MyApp/Areas/Identity/Pages/_ValidationScriptsPartial.cshtml b/MyApp/Areas/Identity/Pages/_ValidationScriptsPartial.cshtml index efa2d88..de8f64e 100644 --- a/MyApp/Areas/Identity/Pages/_ValidationScriptsPartial.cshtml +++ b/MyApp/Areas/Identity/Pages/_ValidationScriptsPartial.cshtml @@ -1,16 +1,16 @@  - - + +