Updating structure
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
**/.git
|
||||
**/.vs
|
||||
**/.idea
|
||||
**/.junie
|
||||
**/bin
|
||||
**/obj
|
||||
**/node_modules
|
||||
*.tmp
|
||||
*.log
|
||||
@@ -0,0 +1 @@
|
||||
[]
|
||||
@@ -0,0 +1 @@
|
||||
3.0
|
||||
@@ -0,0 +1,199 @@
|
||||
---
|
||||
sessionId: session-260925-235814-7y2w
|
||||
---
|
||||
|
||||
# Requirements
|
||||
|
||||
### Overview & Goals
|
||||
The goal is to modernize the ZeroSpace application by introducing **.NET Aspire** orchestration, adding a dedicated backend **GraphQL API service** powered by **Hot Chocolate** with **Nitro UI (Banana Cake Pop)**, integrating a **PostgreSQL database**, containerizing components with **Docker**, and managing deployments on **Kubernetes** using industry-standard tooling (Aspirate / K8s manifests).
|
||||
|
||||
### Scope
|
||||
- **In Scope**:
|
||||
- Creation of `.NET Aspire` AppHost (`ZeroSpace.AppHost`) and shared Service Defaults (`ZeroSpace.ServiceDefaults`).
|
||||
- Creation of an ASP.NET Core GraphQL API service (`ZeroSpace.ApiService`) using **Hot Chocolate** with Entity Framework Core & PostgreSQL (`Npgsql`) support.
|
||||
- Interactive **Nitro UI** (Banana Cake Pop) GraphQL IDE integration at the `/graphql` endpoint for rapid schema exploration and testing.
|
||||
- GraphQL queries, mutations, filtering, sorting, and projections for `UnitData`.
|
||||
- PostgreSQL container provisioning and database connection orchestration via Aspire.
|
||||
- Updating `ZeroSpace.Web` (Blazor WASM) to consume GraphQL operations from `ZeroSpace.ApiService`.
|
||||
- Docker containerization for both `ZeroSpace.ApiService` and `ZeroSpace.Web`.
|
||||
- Kubernetes manifest management and Aspirate integration for cluster deployment.
|
||||
- **Out of Scope**:
|
||||
- Modifying the unit model generation logic in `ZeroSpace.Build`.
|
||||
- Cloud-specific managed Kubernetes cluster creation scripts (e.g. Terraform/Bicep for AKS/EKS).
|
||||
|
||||
### User Stories
|
||||
- **As a Developer**, I want to start the web app, GraphQL API, and PostgreSQL database with a single command using .NET Aspire, and use Nitro UI to interactively explore and query schemas so that local development and testing maximize productivity.
|
||||
- **As an Operator**, I want to generate Kubernetes deployment manifests from the Aspire architecture so that all services and databases deploy consistently to production Kubernetes clusters.
|
||||
- **As a User**, I want to browse and manage ZeroSpace unit data through the Blazor Web UI backed by efficient, precision GraphQL data fetching and persistent database storage.
|
||||
|
||||
### Functional Requirements
|
||||
- **FR-1**: The Aspire AppHost must orchestrate the PostgreSQL container, GraphQL API service, and Web app with configured dependencies and environment variables.
|
||||
- **FR-2**: The `ZeroSpace.ApiService` must expose a Hot Chocolate GraphQL server with schema filtering, sorting, and projection capabilities over PostgreSQL `UnitData`.
|
||||
- **FR-3**: The Nitro UI GraphQL IDE must be hosted and accessible at `/graphql` to enable in-browser schema exploration, query execution, and debugging.
|
||||
- **FR-4**: Database schema migrations and initial seed data from `ZeroSpace.Model.Units.All` must be executed automatically upon startup.
|
||||
- **FR-5**: `ZeroSpace.Web` must execute GraphQL queries/mutations against `ZeroSpace.ApiService` using configured HTTP endpoints.
|
||||
- **FR-6**: Container definitions (Dockerfiles) must exist for all custom applications.
|
||||
- **FR-7**: Kubernetes manifests must specify Deployments, Services, PersistentVolumeClaims, Secrets, and Ingress routing for Web and GraphQL endpoints.
|
||||
|
||||
### Non-Functional Requirements
|
||||
- **Observability**: OpenTelemetry tracing, metrics, and structured logging enabled across services via `ServiceDefaults` (including Hot Chocolate diagnostic tracing).
|
||||
- **Resilience & Health**: Health check endpoints (`/health`, `/alive`) configured for Kubernetes readiness and liveness probes.
|
||||
- **Security**: Database credentials managed through environment variables, Secrets, and Aspire connection strings without hardcoded passwords.
|
||||
- **Query Efficiency**: GraphQL projections ensure only requested fields are fetched from PostgreSQL, minimizing network transfer and database payload overhead.
|
||||
|
||||
# Technical Design
|
||||
|
||||
### Current Implementation
|
||||
- `ZeroSpace.Web` is a standalone Blazor WebAssembly application (`Microsoft.NET.Sdk.BlazorWebAssembly`) targeting .NET 10 with Telerik UI for Blazor.
|
||||
- `ZeroSpace.Model` contains records like `UnitData.cs`, `UnitEfficiency.cs`, and generated static unit arrays in `Units.g.cs`.
|
||||
- `ZeroSpace.Deploy` publishes static files to Azure Static Web Apps.
|
||||
- `ZeroSpace.AppHost`, `ZeroSpace.ServiceDefaults`, and `ZeroSpace.ApiService` are integrated into the solution.
|
||||
|
||||
### Key Decisions
|
||||
1. **Hot Chocolate GraphQL Server with Nitro UI (`ZeroSpace.ApiService`)**:
|
||||
- *Decision*: Introduce Hot Chocolate GraphQL server (`HotChocolate.AspNetCore`, `HotChocolate.Data.EntityFramework`) and enable the embedded Nitro UI (Banana Cake Pop) GraphQL tool suite.
|
||||
- *Rationale*: GraphQL enables client-driven data fetching, reducing over-fetching and network serialization overhead. Nitro UI provides a rich developer and operator environment for real-time schema introspection, query building, and mutation execution directly in the browser.
|
||||
2. **.NET Aspire Orchestration & Aspirate for Kubernetes**:
|
||||
- *Decision*: Use .NET Aspire AppHost for local orchestration and developer dashboard, and use Aspirate (Aspir8) alongside standard Kubernetes manifests.
|
||||
- *Rationale*: Aspire provides official .NET cloud-native developer tooling, OpenTelemetry defaults, and service discovery, while Aspirate transforms the Aspire manifest into production-ready Kubernetes Helm/Kustomize/YAML artifacts.
|
||||
3. **Database Architecture & Seeding**:
|
||||
- *Decision*: Entity Framework Core with `Npgsql.EntityFrameworkCore.PostgreSQL` and PostgreSQL 17 container.
|
||||
- *Rationale*: Native EF Core support with Aspire component `Aspire.Npgsql.EntityFrameworkCore.PostgreSQL` enables automatic connection string binding, resilient retries, and database health checks.
|
||||
|
||||
### Architecture Diagram
|
||||
```mermaid
|
||||
graph TD
|
||||
subgraph Local / Dev [Local .NET Aspire AppHost]
|
||||
AppHost[ZeroSpace.AppHost]
|
||||
Dash[Aspire Dashboard]
|
||||
Nitro[Nitro UI / Banana Cake Pop]
|
||||
AppHost --> Dash
|
||||
AppHost --> Nitro
|
||||
end
|
||||
|
||||
subgraph Kubernetes Cluster [Kubernetes Cluster]
|
||||
Ingress[Kubernetes Ingress / Gateway]
|
||||
|
||||
subgraph Web Pod [Web Deployment]
|
||||
Web[ZeroSpace.Web / Nginx]
|
||||
end
|
||||
|
||||
subgraph API Pod [API Deployment]
|
||||
API[ZeroSpace.ApiService / Hot Chocolate]
|
||||
end
|
||||
|
||||
subgraph DB Pod [PostgreSQL StatefulSet / Pod]
|
||||
DB[(PostgreSQL 17)]
|
||||
PVC[PersistentVolumeClaim]
|
||||
DB --- PVC
|
||||
end
|
||||
end
|
||||
|
||||
Ingress --> Web
|
||||
Ingress --> API
|
||||
Web -->|GraphQL Queries / Mutations| API
|
||||
Nitro -->|Interactive Queries / Schema Docs| API
|
||||
API -->|EF Core / Npgsql| DB
|
||||
```
|
||||
|
||||
### Components
|
||||
- **`ZeroSpace.AppHost`**: Aspire orchestrator project that links PostgreSQL container, `ZeroSpace.ApiService`, and `ZeroSpace.Web`.
|
||||
- **`ZeroSpace.ServiceDefaults`**: Shared project configuring OpenTelemetry, health checks, discovery, and resilience policies.
|
||||
- **`ZeroSpace.ApiService`**: ASP.NET Core GraphQL API powered by Hot Chocolate, hosting Nitro UI at `/graphql`, and handling PostgreSQL persistence with EF Core.
|
||||
- **`ZeroSpace.Web`**: Client-side Blazor WASM UI configured with `IUnitsApiClient` communicating with `ZeroSpace.ApiService` via GraphQL.
|
||||
- **`k8s/`**: Kubernetes manifests (Deployments, Services, PVC, Ingress, Secrets) and Aspirate generation configuration.
|
||||
|
||||
### File Structure
|
||||
```
|
||||
ZeroSpace/
|
||||
├── ZeroSpace.AppHost/
|
||||
│ ├── Program.cs
|
||||
│ ├── appsettings.json
|
||||
│ └── ZeroSpace.AppHost.csproj
|
||||
├── ZeroSpace.ServiceDefaults/
|
||||
│ ├── Extensions.cs
|
||||
│ └── ZeroSpace.ServiceDefaults.csproj
|
||||
├── ZeroSpace.ApiService/
|
||||
│ ├── Data/
|
||||
│ │ ├── ZeroSpaceDbContext.cs
|
||||
│ │ └── DbInitializer.cs
|
||||
│ ├── GraphQL/
|
||||
│ │ ├── Query.cs
|
||||
│ │ ├── Mutation.cs
|
||||
│ │ └── Types/
|
||||
│ ├── Dockerfile
|
||||
│ ├── Program.cs
|
||||
│ └── ZeroSpace.ApiService.csproj
|
||||
├── ZeroSpace.Web/
|
||||
│ ├── Services/
|
||||
│ │ ├── IUnitsApiClient.cs
|
||||
│ │ └── UnitsApiClient.cs
|
||||
│ ├── Dockerfile
|
||||
│ ├── nginx.conf
|
||||
│ └── ...
|
||||
├── k8s/
|
||||
│ ├── postgres-pvc.yaml
|
||||
│ ├── postgres-deployment.yaml
|
||||
│ ├── apiservice-deployment.yaml
|
||||
│ ├── web-deployment.yaml
|
||||
│ └── ingress.yaml
|
||||
├── aspirate.json
|
||||
└── ZeroSpace.sln
|
||||
```
|
||||
|
||||
### Data Models / Contracts
|
||||
```graphql
|
||||
|
||||
# GraphQL Schema Contract
|
||||
|
||||
type Query {
|
||||
units(
|
||||
where: UnitDataFilterInput
|
||||
order: [UnitDataSortInput!]
|
||||
): [UnitData!]!
|
||||
|
||||
unitById(id: String!): UnitData
|
||||
}
|
||||
|
||||
type Mutation {
|
||||
createUnit(input: CreateUnitInput!): UnitData!
|
||||
updateUnit(id: String!, input: UpdateUnitInput!): UnitData!
|
||||
deleteUnit(id: String!): Boolean!
|
||||
}
|
||||
```
|
||||
|
||||
### Risks & Mitigations
|
||||
- **Risk**: GraphQL N+1 or deep query performance impacts.
|
||||
- *Mitigation*: Leverage Hot Chocolate's `[UseProjection]`, `[UseFiltering]`, and `[UseSorting]` which translate GraphQL AST directly into optimized PostgreSQL SQL via EF Core `IQueryable`.
|
||||
- **Risk**: Nitro UI access across different deployment environments.
|
||||
- *Mitigation*: Configure Nitro UI / Banana Cake Pop middleware settings so developer tooling is available locally and behind authorized Ingress routes in staging/production as needed.
|
||||
- **Risk**: Blazor WASM CORS issues when executing GraphQL POST requests.
|
||||
- *Mitigation*: Configure permissive CORS policies in `ZeroSpace.ApiService` for GraphQL endpoints and route requests through Kubernetes Ingress.
|
||||
|
||||
# Testing
|
||||
|
||||
### Validation Approach
|
||||
- Verify local orchestration via `dotnet run --project ZeroSpace.AppHost`.
|
||||
- Access Nitro UI at `http://localhost:5142/graphql` (or through Aspire Dashboard endpoint) to test interactive queries, mutations, and schema documentation.
|
||||
- Test GraphQL queries and mutations executed by `ZeroSpace.Web` against `ZeroSpace.ApiService`.
|
||||
- Build and run Docker containers for `ZeroSpace.ApiService` and `ZeroSpace.Web`.
|
||||
- Validate Kubernetes manifest syntax and Aspirate manifest compilation (`aspirate generate`).
|
||||
|
||||
### Key Scenarios
|
||||
1. **Aspire Local Launch**: Starting `ZeroSpace.AppHost` boots PostgreSQL container, runs EF Core migrations, starts `ZeroSpace.ApiService` with Hot Chocolate GraphQL & Nitro UI, and launches `ZeroSpace.Web`.
|
||||
2. **Nitro UI Interactive Execution**: Open `/graphql` in browser; Banana Cake Pop / Nitro UI loads; execute `{ units { id name hexite flux tier faction } }` and receive structured JSON response.
|
||||
3. **Blazor UI Data Flow**: Navigating to `/units` in the Blazor UI sends a GraphQL query payload to `ZeroSpace.ApiService`, which queries PostgreSQL and returns unit records.
|
||||
4. **Container Build**: `docker build` succeeds for both `ZeroSpace.ApiService` and `ZeroSpace.Web` Dockerfiles.
|
||||
5. **Kubernetes Deployment**: Applying the generated Kubernetes manifests provisions the PVC, deploys PostgreSQL, starts API/Web pods, and exposes them via Service and Ingress.
|
||||
|
||||
### Edge Cases
|
||||
- **PostgreSQL Connection Failures**: GraphQL queries gracefully return structured GraphQL errors and health checks report unhealthy status.
|
||||
- **Empty Database**: Seeding logic populates initial units from `ZeroSpace.Model` if the database table is empty before serving GraphQL requests.
|
||||
- **Complex GraphQL Filtering**: Verify filtering by tier and faction compiles to efficient SQL `WHERE` clauses in PostgreSQL without in-memory buffering.
|
||||
|
||||
# Execution Plan
|
||||
|
||||
### ✓ Step 1: Configure Hot Chocolate GraphQL and Nitro UI in ZeroSpace.ApiService
|
||||
### ✓ Step 2: Update ZeroSpace.Web to consume GraphQL API operations
|
||||
### ✓ Step 3: Update AppHost, Ingress, and Kubernetes configuration for GraphQL endpoint
|
||||
### ✓ Step 4: Build and Verify Solution
|
||||
@@ -0,0 +1,31 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ZeroSpace.Model;
|
||||
|
||||
namespace ZeroSpace.ApiService.Data;
|
||||
|
||||
public static class DbInitializer
|
||||
{
|
||||
public static async Task InitializeDatabaseAsync(IServiceProvider serviceProvider, ILogger logger, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var scope = serviceProvider.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<ZeroSpaceDbContext>();
|
||||
|
||||
logger.LogInformation("Ensuring database schema exists...");
|
||||
await db.Database.EnsureCreatedAsync(cancellationToken);
|
||||
|
||||
if (!await db.Units.AnyAsync(cancellationToken))
|
||||
{
|
||||
logger.LogInformation("Seeding initial units into PostgreSQL...");
|
||||
var initialUnits = Units.All;
|
||||
await db.Units.AddRangeAsync(initialUnits, cancellationToken);
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
logger.LogInformation("Seeded {Count} units into PostgreSQL database.", initialUnits.Count);
|
||||
}
|
||||
else
|
||||
{
|
||||
logger.LogInformation("Database already contains unit data. Skipping seed.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.ChangeTracking;
|
||||
using ZeroSpace.Model;
|
||||
|
||||
namespace ZeroSpace.ApiService.Data;
|
||||
|
||||
public class ZeroSpaceDbContext : DbContext
|
||||
{
|
||||
public ZeroSpaceDbContext(DbContextOptions<ZeroSpaceDbContext> options) : base(options)
|
||||
{
|
||||
}
|
||||
|
||||
public DbSet<UnitData> Units => Set<UnitData>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
base.OnModelCreating(modelBuilder);
|
||||
|
||||
modelBuilder.Entity<UnitData>(entity =>
|
||||
{
|
||||
entity.ToTable("units");
|
||||
entity.HasKey(e => e.Id);
|
||||
|
||||
entity.Property(e => e.Id).HasColumnName("id").HasMaxLength(100);
|
||||
entity.Property(e => e.Name).HasColumnName("name").IsRequired().HasMaxLength(200);
|
||||
entity.Property(e => e.Hexite).HasColumnName("hexite");
|
||||
entity.Property(e => e.Flux).HasColumnName("flux");
|
||||
entity.Property(e => e.Supply).HasColumnName("supply");
|
||||
entity.Property(e => e.ProductionTime).HasColumnName("production_time");
|
||||
entity.Property(e => e.Health).HasColumnName("health");
|
||||
entity.Property(e => e.Energy).HasColumnName("energy");
|
||||
entity.Property(e => e.Shields).HasColumnName("shields");
|
||||
entity.Property(e => e.ArmorRating).HasColumnName("armor_rating");
|
||||
entity.Property(e => e.MovementSpeed).HasColumnName("movement_speed");
|
||||
entity.Property(e => e.DamagePerSecond).HasColumnName("damage_per_second");
|
||||
entity.Property(e => e.AttackRange).HasColumnName("attack_range");
|
||||
entity.Property(e => e.Tier).HasColumnName("tier");
|
||||
entity.Property(e => e.Faction).HasColumnName("faction").IsRequired().HasMaxLength(100);
|
||||
entity.Property(e => e.Hotkey).HasColumnName("hotkey").HasMaxLength(20);
|
||||
entity.Property(e => e.BuildAtSameTime).HasColumnName("build_at_same_time");
|
||||
entity.Property(e => e.Limit).HasColumnName("limit");
|
||||
|
||||
var valueComparer = new ValueComparer<IReadOnlyList<string>>(
|
||||
(c1, c2) => (c1 == null && c2 == null) || (c1 != null && c2 != null && c1.SequenceEqual(c2)),
|
||||
c => c.Aggregate(0, (a, v) => HashCode.Combine(a, v.GetHashCode())),
|
||||
c => c.ToList()
|
||||
);
|
||||
|
||||
entity.Property(e => e.Attributes)
|
||||
.HasColumnName("attributes")
|
||||
.HasConversion(
|
||||
v => JsonSerializer.Serialize(v, (JsonSerializerOptions?)null),
|
||||
v => (IReadOnlyList<string>)(JsonSerializer.Deserialize<List<string>>(v, (JsonSerializerOptions?)null) ?? new List<string>())
|
||||
)
|
||||
.Metadata.SetValueComparer(valueComparer);
|
||||
|
||||
entity.Ignore(e => e.DpsPerHexite);
|
||||
entity.Ignore(e => e.DpsPerFlux);
|
||||
entity.Ignore(e => e.DpsPerTotalCost);
|
||||
entity.Ignore(e => e.HealthPerHexite);
|
||||
entity.Ignore(e => e.HealthPerFlux);
|
||||
entity.Ignore(e => e.HealthPerTotalCost);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
||||
WORKDIR /src
|
||||
|
||||
# Copy project files for caching restore layer
|
||||
COPY ["ZeroSpace.ApiService/ZeroSpace.ApiService.csproj", "ZeroSpace.ApiService/"]
|
||||
COPY ["ZeroSpace.Model/ZeroSpace.Model.csproj", "ZeroSpace.Model/"]
|
||||
COPY ["ZeroSpace.ServiceDefaults/ZeroSpace.ServiceDefaults.csproj", "ZeroSpace.ServiceDefaults/"]
|
||||
|
||||
RUN dotnet restore "ZeroSpace.ApiService/ZeroSpace.ApiService.csproj"
|
||||
|
||||
# Copy source code and build
|
||||
COPY . .
|
||||
WORKDIR "/src/ZeroSpace.ApiService"
|
||||
RUN dotnet publish "ZeroSpace.ApiService.csproj" -c Release -o /app/publish /p:UseAppHost=false
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final
|
||||
WORKDIR /app
|
||||
EXPOSE 8080
|
||||
EXPOSE 8443
|
||||
COPY --from=build /app/publish .
|
||||
ENTRYPOINT ["dotnet", "ZeroSpace.ApiService.dll"]
|
||||
@@ -0,0 +1,93 @@
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ZeroSpace.ApiService.Data;
|
||||
using ZeroSpace.Model;
|
||||
|
||||
namespace ZeroSpace.ApiService.Endpoints;
|
||||
|
||||
public static class UnitsEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapUnitsEndpoints(this IEndpointRouteBuilder routes)
|
||||
{
|
||||
var group = routes.MapGroup("/api/units")
|
||||
.WithTags("Units");
|
||||
|
||||
group.MapGet("/", async (ZeroSpaceDbContext db, string? faction, int? tier) =>
|
||||
{
|
||||
IQueryable<UnitData> query = db.Units.AsNoTracking();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(faction))
|
||||
{
|
||||
query = query.Where(u => u.Faction == faction);
|
||||
}
|
||||
|
||||
if (tier.HasValue)
|
||||
{
|
||||
query = query.Where(u => u.Tier == tier.Value);
|
||||
}
|
||||
|
||||
var units = await query.ToListAsync();
|
||||
return Results.Ok(units);
|
||||
})
|
||||
.WithName("GetAllUnits")
|
||||
.WithSummary("Get all units optionally filtered by faction and tier.");
|
||||
|
||||
group.MapGet("/{id}", async (string id, ZeroSpaceDbContext db) =>
|
||||
{
|
||||
var unit = await db.Units.AsNoTracking().FirstOrDefaultAsync(u => u.Id == id);
|
||||
return unit is not null ? Results.Ok(unit) : Results.NotFound();
|
||||
})
|
||||
.WithName("GetUnitById")
|
||||
.WithSummary("Get a unit by id.");
|
||||
|
||||
group.MapPost("/", async (UnitData unit, ZeroSpaceDbContext db) =>
|
||||
{
|
||||
var exists = await db.Units.AnyAsync(u => u.Id == unit.Id);
|
||||
if (exists)
|
||||
{
|
||||
return Results.Conflict($"Unit with id '{unit.Id}' already exists.");
|
||||
}
|
||||
|
||||
db.Units.Add(unit);
|
||||
await db.SaveChangesAsync();
|
||||
return Results.Created($"/api/units/{unit.Id}", unit);
|
||||
})
|
||||
.WithName("CreateUnit")
|
||||
.WithSummary("Create a new unit.");
|
||||
|
||||
group.MapPut("/{id}", async (string id, UnitData updatedUnit, ZeroSpaceDbContext db) =>
|
||||
{
|
||||
var unit = await db.Units.FindAsync(id);
|
||||
if (unit is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
db.Units.Remove(unit);
|
||||
db.Units.Add(updatedUnit with { Id = id });
|
||||
await db.SaveChangesAsync();
|
||||
return Results.Ok(updatedUnit with { Id = id });
|
||||
})
|
||||
.WithName("UpdateUnit")
|
||||
.WithSummary("Update an existing unit.");
|
||||
|
||||
group.MapDelete("/{id}", async (string id, ZeroSpaceDbContext db) =>
|
||||
{
|
||||
var unit = await db.Units.FindAsync(id);
|
||||
if (unit is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
db.Units.Remove(unit);
|
||||
await db.SaveChangesAsync();
|
||||
return Results.NoContent();
|
||||
})
|
||||
.WithName("DeleteUnit")
|
||||
.WithSummary("Delete a unit by id.");
|
||||
|
||||
return routes;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
using HotChocolate;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ZeroSpace.ApiService.Data;
|
||||
using ZeroSpace.Model;
|
||||
|
||||
namespace ZeroSpace.ApiService.GraphQL;
|
||||
|
||||
public record CreateUnitInput(
|
||||
string Id,
|
||||
string Name,
|
||||
int Hexite,
|
||||
int Flux,
|
||||
int Supply,
|
||||
int ProductionTime,
|
||||
int Health,
|
||||
int Energy,
|
||||
int Shields,
|
||||
int ArmorRating,
|
||||
int MovementSpeed,
|
||||
int DamagePerSecond,
|
||||
int AttackRange,
|
||||
IReadOnlyList<string>? Attributes,
|
||||
int Tier,
|
||||
string Faction,
|
||||
string? Hotkey,
|
||||
int BuildAtSameTime,
|
||||
int? Limit
|
||||
);
|
||||
|
||||
public record UpdateUnitInput(
|
||||
string Name,
|
||||
int Hexite,
|
||||
int Flux,
|
||||
int Supply,
|
||||
int ProductionTime,
|
||||
int Health,
|
||||
int Energy,
|
||||
int Shields,
|
||||
int ArmorRating,
|
||||
int MovementSpeed,
|
||||
int DamagePerSecond,
|
||||
int AttackRange,
|
||||
IReadOnlyList<string>? Attributes,
|
||||
int Tier,
|
||||
string Faction,
|
||||
string? Hotkey,
|
||||
int BuildAtSameTime,
|
||||
int? Limit
|
||||
);
|
||||
|
||||
public class Mutation
|
||||
{
|
||||
public async Task<UnitData> CreateUnitAsync(CreateUnitInput input, ZeroSpaceDbContext db, CancellationToken cancellationToken)
|
||||
{
|
||||
var exists = await db.Units.AnyAsync(u => u.Id == input.Id, cancellationToken);
|
||||
if (exists)
|
||||
{
|
||||
throw new GraphQLException(ErrorBuilder.New().SetMessage($"Unit with id '{input.Id}' already exists.").SetCode("UNIT_ALREADY_EXISTS").Build());
|
||||
}
|
||||
|
||||
var unit = new UnitData(
|
||||
input.Id,
|
||||
input.Name,
|
||||
input.Hexite,
|
||||
input.Flux,
|
||||
input.Supply,
|
||||
input.ProductionTime,
|
||||
input.Health,
|
||||
input.Energy,
|
||||
input.Shields,
|
||||
input.ArmorRating,
|
||||
input.MovementSpeed,
|
||||
input.DamagePerSecond,
|
||||
input.AttackRange,
|
||||
input.Attributes ?? new List<string>(),
|
||||
input.Tier,
|
||||
input.Faction,
|
||||
input.Hotkey,
|
||||
input.BuildAtSameTime,
|
||||
input.Limit
|
||||
);
|
||||
|
||||
db.Units.Add(unit);
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
return unit;
|
||||
}
|
||||
|
||||
public async Task<UnitData> UpdateUnitAsync(string id, UpdateUnitInput input, ZeroSpaceDbContext db, CancellationToken cancellationToken)
|
||||
{
|
||||
var existing = await db.Units.FindAsync(new object[] { id }, cancellationToken);
|
||||
if (existing is null)
|
||||
{
|
||||
throw new GraphQLException(ErrorBuilder.New().SetMessage($"Unit with id '{id}' not found.").SetCode("UNIT_NOT_FOUND").Build());
|
||||
}
|
||||
|
||||
var updated = new UnitData(
|
||||
id,
|
||||
input.Name,
|
||||
input.Hexite,
|
||||
input.Flux,
|
||||
input.Supply,
|
||||
input.ProductionTime,
|
||||
input.Health,
|
||||
input.Energy,
|
||||
input.Shields,
|
||||
input.ArmorRating,
|
||||
input.MovementSpeed,
|
||||
input.DamagePerSecond,
|
||||
input.AttackRange,
|
||||
input.Attributes ?? new List<string>(),
|
||||
input.Tier,
|
||||
input.Faction,
|
||||
input.Hotkey,
|
||||
input.BuildAtSameTime,
|
||||
input.Limit
|
||||
);
|
||||
|
||||
db.Units.Remove(existing);
|
||||
db.Units.Add(updated);
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
return updated;
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteUnitAsync(string id, ZeroSpaceDbContext db, CancellationToken cancellationToken)
|
||||
{
|
||||
var unit = await db.Units.FindAsync(new object[] { id }, cancellationToken);
|
||||
if (unit is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
db.Units.Remove(unit);
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using HotChocolate;
|
||||
using HotChocolate.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ZeroSpace.ApiService.Data;
|
||||
using ZeroSpace.Model;
|
||||
|
||||
namespace ZeroSpace.ApiService.GraphQL;
|
||||
|
||||
public class Query
|
||||
{
|
||||
[UseProjection]
|
||||
[UseFiltering]
|
||||
[UseSorting]
|
||||
public IQueryable<UnitData> GetUnits(ZeroSpaceDbContext db) =>
|
||||
db.Units.AsNoTracking();
|
||||
|
||||
public async Task<UnitData?> GetUnitByIdAsync(string id, ZeroSpaceDbContext db, CancellationToken cancellationToken) =>
|
||||
await db.Units.AsNoTracking().FirstOrDefaultAsync(u => u.Id == id, cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
using ZeroSpace.ApiService.Data;
|
||||
using ZeroSpace.ApiService.Endpoints;
|
||||
using ZeroSpace.ApiService.GraphQL;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// Add service defaults & Aspire components
|
||||
builder.AddServiceDefaults();
|
||||
|
||||
// Add EF Core with PostgreSQL connection from Aspire ("zerospacedb")
|
||||
builder.AddNpgsqlDbContext<ZeroSpaceDbContext>("zerospacedb");
|
||||
|
||||
// Add CORS for Web client
|
||||
builder.Services.AddCors(options =>
|
||||
{
|
||||
options.AddDefaultPolicy(policy =>
|
||||
{
|
||||
policy.AllowAnyOrigin()
|
||||
.AllowAnyHeader()
|
||||
.AllowAnyMethod();
|
||||
});
|
||||
});
|
||||
|
||||
// Configure Hot Chocolate GraphQL Server with Nitro UI (Banana Cake Pop)
|
||||
builder.Services
|
||||
.AddGraphQLServer()
|
||||
.AddQueryType<Query>()
|
||||
.AddMutationType<Mutation>()
|
||||
.AddProjections()
|
||||
.AddFiltering()
|
||||
.AddSorting();
|
||||
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
builder.Services.AddOpenApi();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
app.UseCors();
|
||||
|
||||
app.MapDefaultEndpoints();
|
||||
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
app.MapOpenApi();
|
||||
}
|
||||
|
||||
// Map GraphQL endpoint with Nitro UI (Banana Cake Pop) at /graphql
|
||||
app.MapGraphQL();
|
||||
|
||||
// Optional REST endpoints for auxiliary compatibility
|
||||
app.MapUnitsEndpoints();
|
||||
|
||||
// Initialize database schema and initial data
|
||||
try
|
||||
{
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
|
||||
await DbInitializer.InitializeDatabaseAsync(app.Services, app.Logger, cts.Token);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
app.Logger.LogError(ex, "An error occurred while initializing the database.");
|
||||
}
|
||||
|
||||
await app.RunAsync();
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/launchsettings.json",
|
||||
"profiles": {
|
||||
"https": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": false,
|
||||
"applicationUrl": "https://localhost:7142;http://localhost:5142",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
},
|
||||
"http": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": false,
|
||||
"applicationUrl": "http://localhost:5142",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Aspire.Npgsql.EntityFrameworkCore.PostgreSQL" Version="13.5.4" />
|
||||
<PackageReference Include="HotChocolate.AspNetCore" Version="16.6.7" />
|
||||
<PackageReference Include="HotChocolate.Data.EntityFramework" Version="16.6.7" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.12" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\ZeroSpace.Model\ZeroSpace.Model.csproj" />
|
||||
<ProjectReference Include="..\ZeroSpace.ServiceDefaults\ZeroSpace.ServiceDefaults.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*"
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
var builder = DistributedApplication.CreateBuilder(args);
|
||||
|
||||
var postgres = builder.AddPostgres("postgres")
|
||||
.WithDataVolume()
|
||||
.AddDatabase("zerospacedb");
|
||||
|
||||
var apiService = builder.AddProject<Projects.ZeroSpace_ApiService>("apiservice")
|
||||
.WithReference(postgres)
|
||||
.WaitFor(postgres);
|
||||
|
||||
var web = builder.AddProject<Projects.ZeroSpace_Web>("web")
|
||||
.WithReference(apiService)
|
||||
.WaitFor(apiService);
|
||||
|
||||
builder.Build().Run();
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/launchsettings.json",
|
||||
"profiles": {
|
||||
"https": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": true,
|
||||
"applicationUrl": "https://localhost:17228;http://localhost:15228",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development",
|
||||
"DOTNET_ENVIRONMENT": "Development",
|
||||
"DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21145",
|
||||
"ASPIRE_ALLOW_UNSECURED_TRANSPORT": "true"
|
||||
}
|
||||
},
|
||||
"http": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": true,
|
||||
"applicationUrl": "http://localhost:15228",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development",
|
||||
"DOTNET_ENVIRONMENT": "Development",
|
||||
"DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19145",
|
||||
"ASPIRE_ALLOW_UNSECURED_TRANSPORT": "true"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<Project Sdk="Aspire.AppHost.Sdk/13.5.4">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsAspireHost>true</IsAspireHost>
|
||||
<UserSecretsId>zerospace-apphost-secrets</UserSecretsId>
|
||||
<NoWarn>$(NoWarn);ASPIRE010</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Aspire.Hosting.AppHost" Version="13.5.4" />
|
||||
<PackageReference Include="Aspire.Hosting.PostgreSQL" Version="13.5.4" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\ZeroSpace.ApiService\ZeroSpace.ApiService.csproj" />
|
||||
<ProjectReference Include="..\ZeroSpace.Web\ZeroSpace.Web.csproj" />
|
||||
<ProjectReference Include="..\ZeroSpace.ServiceDefaults\ZeroSpace.ServiceDefaults.csproj" IsAspireProjectResource="false" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"DetailedErrors": true,
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning",
|
||||
"ResourceLifecycle": "Information"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning",
|
||||
"ResourceLifecycle": "Information"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,402 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
|
||||
var solutionRoot = FindSolutionRoot();
|
||||
var docsRoot = args.Length > 0
|
||||
? Path.GetFullPath(args[0])
|
||||
: Path.GetFullPath(Path.Combine(solutionRoot, "..", "zs.docs"));
|
||||
var modelRoot = Path.Combine(solutionRoot, "ZeroSpace.Model");
|
||||
var outputPath = Path.Combine(modelRoot, "Units.g.cs");
|
||||
|
||||
if (!Directory.Exists(docsRoot))
|
||||
{
|
||||
throw new DirectoryNotFoundException($"Could not find docs directory: {docsRoot}");
|
||||
}
|
||||
|
||||
if (!Directory.Exists(modelRoot))
|
||||
{
|
||||
throw new DirectoryNotFoundException($"Could not find model project directory: {modelRoot}");
|
||||
}
|
||||
|
||||
var units = new List<UnitSource>();
|
||||
var skipped = new List<string>();
|
||||
|
||||
foreach (var path in Directory.EnumerateFiles(docsRoot, "*.md", SearchOption.AllDirectories).Order(StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
var frontMatter = ReadFrontMatter(path);
|
||||
if (frontMatter is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!IsUnit(frontMatter))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!TryReadUnit(path, frontMatter, out var unit, out var reason))
|
||||
{
|
||||
skipped.Add($"{Path.GetFileName(path)} ({reason})");
|
||||
continue;
|
||||
}
|
||||
|
||||
units.Add(unit);
|
||||
}
|
||||
|
||||
units.Sort((left, right) =>
|
||||
{
|
||||
var faction = string.Compare(left.Faction, right.Faction, StringComparison.OrdinalIgnoreCase);
|
||||
if (faction != 0)
|
||||
{
|
||||
return faction;
|
||||
}
|
||||
|
||||
var tier = left.Tier.CompareTo(right.Tier);
|
||||
return tier != 0
|
||||
? tier
|
||||
: string.Compare(left.Name, right.Name, StringComparison.OrdinalIgnoreCase);
|
||||
});
|
||||
|
||||
File.WriteAllText(outputPath, GenerateUnits(units), Encoding.UTF8);
|
||||
|
||||
Console.WriteLine($"Generated {units.Count} units: {Path.GetRelativePath(solutionRoot, outputPath)}");
|
||||
if (skipped.Count > 0)
|
||||
{
|
||||
Console.WriteLine($"Skipped {skipped.Count} incomplete unit files:");
|
||||
foreach (var entry in skipped.Order(StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
Console.WriteLine($"- {entry}");
|
||||
}
|
||||
}
|
||||
|
||||
static string FindSolutionRoot()
|
||||
{
|
||||
var candidates = new[] { Environment.CurrentDirectory, AppContext.BaseDirectory };
|
||||
|
||||
foreach (var candidate in candidates)
|
||||
{
|
||||
var directory = new DirectoryInfo(candidate);
|
||||
|
||||
while (directory is not null)
|
||||
{
|
||||
if (File.Exists(Path.Combine(directory.FullName, "ZeroSpace.sln")))
|
||||
{
|
||||
return directory.FullName;
|
||||
}
|
||||
|
||||
if (File.Exists(Path.Combine(directory.FullName, "ZeroSpace", "ZeroSpace.sln")))
|
||||
{
|
||||
return Path.Combine(directory.FullName, "ZeroSpace");
|
||||
}
|
||||
|
||||
directory = directory.Parent;
|
||||
}
|
||||
}
|
||||
|
||||
throw new DirectoryNotFoundException("Could not find ZeroSpace.sln in the current directory or its parents.");
|
||||
}
|
||||
|
||||
static Dictionary<string, FrontMatterValue>? ReadFrontMatter(string path)
|
||||
{
|
||||
var lines = File.ReadAllLines(path);
|
||||
if (lines.Length == 0 || lines[0].Trim() != "---")
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var values = new Dictionary<string, FrontMatterValue>(StringComparer.OrdinalIgnoreCase);
|
||||
string? listKey = null;
|
||||
|
||||
for (var i = 1; i < lines.Length; i++)
|
||||
{
|
||||
var line = lines[i];
|
||||
if (line.Trim() == "---")
|
||||
{
|
||||
return values;
|
||||
}
|
||||
|
||||
if (listKey is not null && line.StartsWith(" - ", StringComparison.Ordinal))
|
||||
{
|
||||
values[listKey].List.Add(CleanValue(line[4..]));
|
||||
continue;
|
||||
}
|
||||
|
||||
listKey = null;
|
||||
var separatorIndex = line.IndexOf(':');
|
||||
if (separatorIndex < 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var key = line[..separatorIndex].Trim();
|
||||
var rawValue = line[(separatorIndex + 1)..].Trim();
|
||||
if (rawValue.Length == 0)
|
||||
{
|
||||
values[key] = new FrontMatterValue(null);
|
||||
listKey = key;
|
||||
continue;
|
||||
}
|
||||
|
||||
values[key] = new FrontMatterValue(CleanValue(rawValue));
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
static bool IsUnit(IReadOnlyDictionary<string, FrontMatterValue> frontMatter)
|
||||
{
|
||||
return frontMatter.TryGetValue("category", out var category)
|
||||
&& string.Equals(category.Scalar, "Unit", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
static bool TryReadUnit(
|
||||
string path,
|
||||
IReadOnlyDictionary<string, FrontMatterValue> frontMatter,
|
||||
out UnitSource unit,
|
||||
out string reason)
|
||||
{
|
||||
unit = default!;
|
||||
|
||||
if (!TryGetRequiredString(frontMatter, "Faction", out var faction, out reason) ||
|
||||
!TryGetRequiredInt(frontMatter, "hexite", out var hexite, out reason) ||
|
||||
!TryGetRequiredInt(frontMatter, "Flux", out var flux, out reason) ||
|
||||
!TryGetRequiredInt(frontMatter, "supply", out var supply, out reason) ||
|
||||
!TryGetRequiredInt(frontMatter, "productionTime", out var productionTime, out reason) ||
|
||||
!TryGetRequiredInt(frontMatter, "health", out var health, out reason) ||
|
||||
!TryGetRequiredInt(frontMatter, "Energy", out var energy, out reason) ||
|
||||
!TryGetRequiredInt(frontMatter, "Shields", out var shields, out reason) ||
|
||||
!TryGetRequiredInt(frontMatter, "Armor rating", out var armorRating, out reason) ||
|
||||
!TryGetRequiredInt(frontMatter, "movementSpeed", out var movementSpeed, out reason) ||
|
||||
!TryGetRequiredInt(frontMatter, "damagePerSecond", out var damagePerSecond, out reason) ||
|
||||
!TryGetRequiredInt(frontMatter, "attackRange", out var attackRange, out reason) ||
|
||||
!TryGetRequiredInt(frontMatter, "Tier", out var tier, out reason) ||
|
||||
!TryGetRequiredInt(frontMatter, "buildAtSameTime", out var buildAtSameTime, out reason))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var id = ToIdentifier(Path.GetFileNameWithoutExtension(path));
|
||||
var hotkey = TryGetOptionalString(frontMatter, "Hotkey");
|
||||
var attributes = frontMatter.TryGetValue("attributes", out var attributeValue)
|
||||
? attributeValue.List.Where(static value => !string.IsNullOrWhiteSpace(value)).ToArray()
|
||||
: [];
|
||||
var limit = TryGetOptionalInt(frontMatter, "limit");
|
||||
|
||||
unit = new UnitSource(
|
||||
id,
|
||||
Path.GetFileNameWithoutExtension(path),
|
||||
hexite,
|
||||
flux,
|
||||
supply,
|
||||
productionTime,
|
||||
health,
|
||||
energy,
|
||||
shields,
|
||||
armorRating,
|
||||
movementSpeed,
|
||||
damagePerSecond,
|
||||
attackRange,
|
||||
attributes,
|
||||
tier,
|
||||
faction,
|
||||
hotkey,
|
||||
buildAtSameTime,
|
||||
limit);
|
||||
reason = string.Empty;
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool TryGetRequiredString(
|
||||
IReadOnlyDictionary<string, FrontMatterValue> frontMatter,
|
||||
string key,
|
||||
out string value,
|
||||
out string reason)
|
||||
{
|
||||
if (!frontMatter.TryGetValue(key, out var frontMatterValue) ||
|
||||
string.IsNullOrWhiteSpace(frontMatterValue.Scalar))
|
||||
{
|
||||
value = string.Empty;
|
||||
reason = $"missing {key}";
|
||||
return false;
|
||||
}
|
||||
|
||||
value = frontMatterValue.Scalar;
|
||||
reason = string.Empty;
|
||||
return true;
|
||||
}
|
||||
|
||||
static string? TryGetOptionalString(IReadOnlyDictionary<string, FrontMatterValue> frontMatter, string key)
|
||||
{
|
||||
return frontMatter.TryGetValue(key, out var frontMatterValue) &&
|
||||
!string.IsNullOrWhiteSpace(frontMatterValue.Scalar)
|
||||
? frontMatterValue.Scalar
|
||||
: null;
|
||||
}
|
||||
|
||||
static bool TryGetRequiredInt(
|
||||
IReadOnlyDictionary<string, FrontMatterValue> frontMatter,
|
||||
string key,
|
||||
out int value,
|
||||
out string reason)
|
||||
{
|
||||
if (!frontMatter.TryGetValue(key, out var frontMatterValue) ||
|
||||
string.IsNullOrWhiteSpace(frontMatterValue.Scalar))
|
||||
{
|
||||
value = default;
|
||||
reason = $"missing {key}";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!int.TryParse(frontMatterValue.Scalar, NumberStyles.Integer, CultureInfo.InvariantCulture, out value))
|
||||
{
|
||||
reason = $"invalid {key}";
|
||||
return false;
|
||||
}
|
||||
|
||||
reason = string.Empty;
|
||||
return true;
|
||||
}
|
||||
|
||||
static int? TryGetOptionalInt(IReadOnlyDictionary<string, FrontMatterValue> frontMatter, string key)
|
||||
{
|
||||
if (!frontMatter.TryGetValue(key, out var frontMatterValue) ||
|
||||
string.IsNullOrWhiteSpace(frontMatterValue.Scalar))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return int.TryParse(frontMatterValue.Scalar, NumberStyles.Integer, CultureInfo.InvariantCulture, out var value)
|
||||
? value
|
||||
: null;
|
||||
}
|
||||
|
||||
static string CleanValue(string value)
|
||||
{
|
||||
value = value.Trim();
|
||||
|
||||
if (value.Length >= 2 && value[0] == '"' && value[^1] == '"')
|
||||
{
|
||||
value = value[1..^1];
|
||||
}
|
||||
|
||||
if (value.StartsWith("[[", StringComparison.Ordinal) && value.EndsWith("]]", StringComparison.Ordinal))
|
||||
{
|
||||
value = value[2..^2];
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
static string ToIdentifier(string name)
|
||||
{
|
||||
var builder = new StringBuilder();
|
||||
var capitalizeNext = true;
|
||||
|
||||
foreach (var character in name)
|
||||
{
|
||||
if (char.IsLetterOrDigit(character))
|
||||
{
|
||||
builder.Append(capitalizeNext ? char.ToUpperInvariant(character) : character);
|
||||
capitalizeNext = false;
|
||||
}
|
||||
else
|
||||
{
|
||||
capitalizeNext = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (builder.Length == 0)
|
||||
{
|
||||
return "Unit";
|
||||
}
|
||||
|
||||
if (char.IsDigit(builder[0]))
|
||||
{
|
||||
builder.Insert(0, 'U');
|
||||
}
|
||||
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
static string GenerateUnits(IReadOnlyList<UnitSource> units)
|
||||
{
|
||||
var builder = new StringBuilder();
|
||||
builder.AppendLine("// <auto-generated />");
|
||||
builder.AppendLine("namespace ZeroSpace.Model;");
|
||||
builder.AppendLine();
|
||||
builder.AppendLine("public static class Units");
|
||||
builder.AppendLine("{");
|
||||
|
||||
foreach (var unit in units)
|
||||
{
|
||||
builder.AppendLine($" public static readonly UnitData {unit.Id} = new(");
|
||||
builder.AppendLine($" Id: {Literal(unit.Id)},");
|
||||
builder.AppendLine($" Name: {Literal(unit.Name)},");
|
||||
builder.AppendLine($" Hexite: {unit.Hexite.ToString(CultureInfo.InvariantCulture)},");
|
||||
builder.AppendLine($" Flux: {unit.Flux.ToString(CultureInfo.InvariantCulture)},");
|
||||
builder.AppendLine($" Supply: {unit.Supply.ToString(CultureInfo.InvariantCulture)},");
|
||||
builder.AppendLine($" ProductionTime: {unit.ProductionTime.ToString(CultureInfo.InvariantCulture)},");
|
||||
builder.AppendLine($" Health: {unit.Health.ToString(CultureInfo.InvariantCulture)},");
|
||||
builder.AppendLine($" Energy: {unit.Energy.ToString(CultureInfo.InvariantCulture)},");
|
||||
builder.AppendLine($" Shields: {unit.Shields.ToString(CultureInfo.InvariantCulture)},");
|
||||
builder.AppendLine($" ArmorRating: {unit.ArmorRating.ToString(CultureInfo.InvariantCulture)},");
|
||||
builder.AppendLine($" MovementSpeed: {unit.MovementSpeed.ToString(CultureInfo.InvariantCulture)},");
|
||||
builder.AppendLine($" DamagePerSecond: {unit.DamagePerSecond.ToString(CultureInfo.InvariantCulture)},");
|
||||
builder.AppendLine($" AttackRange: {unit.AttackRange.ToString(CultureInfo.InvariantCulture)},");
|
||||
builder.AppendLine($" Attributes: [{string.Join(", ", unit.Attributes.Select(Literal))}],");
|
||||
builder.AppendLine($" Tier: {unit.Tier.ToString(CultureInfo.InvariantCulture)},");
|
||||
builder.AppendLine($" Faction: {Literal(unit.Faction)},");
|
||||
builder.AppendLine($" Hotkey: {Literal(unit.Hotkey)},");
|
||||
builder.AppendLine($" BuildAtSameTime: {unit.BuildAtSameTime.ToString(CultureInfo.InvariantCulture)},");
|
||||
builder.AppendLine($" Limit: {unit.Limit?.ToString(CultureInfo.InvariantCulture) ?? "null"});");
|
||||
builder.AppendLine();
|
||||
}
|
||||
|
||||
builder.AppendLine(" public static IReadOnlyList<UnitData> All { get; } =");
|
||||
builder.AppendLine(" [");
|
||||
|
||||
foreach (var unit in units)
|
||||
{
|
||||
builder.AppendLine($" {unit.Id},");
|
||||
}
|
||||
|
||||
builder.AppendLine(" ];");
|
||||
builder.AppendLine("}");
|
||||
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
static string Literal(string? value)
|
||||
{
|
||||
return value is null
|
||||
? "null"
|
||||
: "\"" + value.Replace("\\", "\\\\").Replace("\"", "\\\"") + "\"";
|
||||
}
|
||||
|
||||
internal sealed class FrontMatterValue(string? scalar)
|
||||
{
|
||||
public string? Scalar { get; } = scalar;
|
||||
|
||||
public List<string> List { get; } = [];
|
||||
}
|
||||
|
||||
internal sealed record UnitSource(
|
||||
string Id,
|
||||
string Name,
|
||||
int Hexite,
|
||||
int Flux,
|
||||
int Supply,
|
||||
int ProductionTime,
|
||||
int Health,
|
||||
int Energy,
|
||||
int Shields,
|
||||
int ArmorRating,
|
||||
int MovementSpeed,
|
||||
int DamagePerSecond,
|
||||
int AttackRange,
|
||||
IReadOnlyList<string> Attributes,
|
||||
int Tier,
|
||||
string Faction,
|
||||
string? Hotkey,
|
||||
int BuildAtSameTime,
|
||||
int? Limit);
|
||||
@@ -0,0 +1,10 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,33 @@
|
||||
using System.Diagnostics;
|
||||
|
||||
var deployDir = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", ".."));
|
||||
var webDir = Path.GetFullPath(Path.Combine(deployDir, "..", "ZeroSpace.Web"));
|
||||
var webProject = Path.Combine(webDir, "ZeroSpace.Web.csproj");
|
||||
var publishDir = Path.Combine(Path.GetTempPath(), "zs-deploy", Guid.NewGuid().ToString());
|
||||
var deploymentToken = Environment.GetEnvironmentVariable("ZS_DeployToken") ?? throw new InvalidOperationException("ZS_DeployToken environment variable not set.");
|
||||
var deployEnv = Environment.GetEnvironmentVariable("ZS_DeployEnv") ?? "preview";
|
||||
|
||||
// 1. Publish
|
||||
Console.WriteLine("Publishing Web project...");
|
||||
Run("dotnet", $"publish \"{webProject}\" -c Release -o \"{publishDir}\"");
|
||||
|
||||
var wwwroot = Path.Combine(publishDir, "wwwroot");
|
||||
if (!Directory.Exists(wwwroot))
|
||||
{
|
||||
Console.Error.WriteLine("wwwroot not found in publish output.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
// 2. Deploy
|
||||
Console.WriteLine("Deploying to Azure Static Web Apps...");
|
||||
Run("swa.cmd", $"deploy \"{wwwroot}\" --deployment-token \"{deploymentToken}\" --app-location \"{webDir}\" --env \"{deployEnv}\"");
|
||||
|
||||
Console.WriteLine("Deploy successful!");
|
||||
return 0;
|
||||
|
||||
static void Run(string fileName, string arguments)
|
||||
{
|
||||
var process = Process.Start(new ProcessStartInfo(fileName, arguments) { UseShellExecute = false })!;
|
||||
process.WaitForExit();
|
||||
if (process.ExitCode != 0) { Environment.Exit(process.ExitCode); }
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,32 @@
|
||||
namespace ZeroSpace.Model;
|
||||
|
||||
public sealed record UnitData(
|
||||
string Id,
|
||||
string Name,
|
||||
int Hexite,
|
||||
int Flux,
|
||||
int Supply,
|
||||
int ProductionTime,
|
||||
int Health,
|
||||
int Energy,
|
||||
int Shields,
|
||||
int ArmorRating,
|
||||
int MovementSpeed,
|
||||
int DamagePerSecond,
|
||||
int AttackRange,
|
||||
IReadOnlyList<string> Attributes,
|
||||
int Tier,
|
||||
string Faction,
|
||||
string? Hotkey,
|
||||
int BuildAtSameTime,
|
||||
int? Limit)
|
||||
{
|
||||
public double DpsPerHexite => UnitEfficiency.CalculateDpsPerHexite(DamagePerSecond, Hexite);
|
||||
public double DpsPerFlux => UnitEfficiency.CalculateDpsPerFlux(DamagePerSecond, Flux);
|
||||
public double DpsPerTotalCost => UnitEfficiency.CalculateDpsPerTotalCost(DamagePerSecond, Hexite, Flux);
|
||||
|
||||
public double HealthPerHexite => UnitEfficiency.CalculateHealthPerHexite(Health, Hexite);
|
||||
public double HealthPerFlux => UnitEfficiency.CalculateHealthPerFlux(Health, Flux);
|
||||
public double HealthPerTotalCost => UnitEfficiency.CalculateHealthPerTotalCost(Health, Hexite, Flux);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace ZeroSpace.Model;
|
||||
|
||||
public static class UnitEfficiency
|
||||
{
|
||||
public static double CalculateDpsPerHexite(int damagePerSecond, int hexite) => CalculateEfficiency(damagePerSecond, hexite);
|
||||
public static double CalculateDpsPerFlux(int damagePerSecond, int flux) => CalculateEfficiency(damagePerSecond, flux);
|
||||
public static double CalculateDpsPerTotalCost(int damagePerSecond, int hexite, int flux) => CalculateEfficiency(damagePerSecond, hexite + flux);
|
||||
|
||||
public static double CalculateHealthPerHexite(int health, int hexite) => CalculateEfficiency(health, hexite);
|
||||
public static double CalculateHealthPerFlux(int health, int flux) => CalculateEfficiency(health, flux);
|
||||
public static double CalculateHealthPerTotalCost(int health, int hexite, int flux) => CalculateEfficiency(health, hexite + flux);
|
||||
|
||||
private static double CalculateEfficiency(int value, int cost)
|
||||
{
|
||||
if (cost <= 0)
|
||||
{
|
||||
return value > 0 ? double.PositiveInfinity : 0;
|
||||
}
|
||||
|
||||
return (double)value / cost;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
// <auto-generated />
|
||||
namespace ZeroSpace.Model;
|
||||
|
||||
public static class Units
|
||||
{
|
||||
public static readonly UnitData CorsairRaiders = new(
|
||||
Id: "CorsairRaiders",
|
||||
Name: "Corsair Raiders",
|
||||
Hexite: 58,
|
||||
Flux: 0,
|
||||
Supply: 2,
|
||||
ProductionTime: 30,
|
||||
Health: 175,
|
||||
Energy: 0,
|
||||
Shields: 0,
|
||||
ArmorRating: 0,
|
||||
MovementSpeed: 650,
|
||||
DamagePerSecond: 8,
|
||||
AttackRange: 8,
|
||||
Attributes: ["Light Armor", "Biological"],
|
||||
Tier: 1,
|
||||
Faction: "Corsair",
|
||||
Hotkey: "Z",
|
||||
BuildAtSameTime: 3,
|
||||
Limit: null);
|
||||
|
||||
public static readonly UnitData CorsairRovers = new(
|
||||
Id: "CorsairRovers",
|
||||
Name: "Corsair Rovers",
|
||||
Hexite: 100,
|
||||
Flux: 25,
|
||||
Supply: 3,
|
||||
ProductionTime: 35,
|
||||
Health: 320,
|
||||
Energy: 0,
|
||||
Shields: 0,
|
||||
ArmorRating: 0,
|
||||
MovementSpeed: 750,
|
||||
DamagePerSecond: 17,
|
||||
AttackRange: 7,
|
||||
Attributes: ["Light Armor"],
|
||||
Tier: 1,
|
||||
Faction: "Corsair",
|
||||
Hotkey: "X",
|
||||
BuildAtSameTime: 2,
|
||||
Limit: null);
|
||||
|
||||
public static readonly UnitData CorsairSnipers = new(
|
||||
Id: "CorsairSnipers",
|
||||
Name: "Corsair Snipers",
|
||||
Hexite: 38,
|
||||
Flux: 100,
|
||||
Supply: 4,
|
||||
ProductionTime: 30,
|
||||
Health: 100,
|
||||
Energy: 0,
|
||||
Shields: 100,
|
||||
ArmorRating: 0,
|
||||
MovementSpeed: 425,
|
||||
DamagePerSecond: 14,
|
||||
AttackRange: 20,
|
||||
Attributes: ["Light Armor", "Biological", "Bonus Damage vs Light Armor"],
|
||||
Tier: 1,
|
||||
Faction: "Corsair",
|
||||
Hotkey: "C",
|
||||
BuildAtSameTime: 2,
|
||||
Limit: null);
|
||||
|
||||
public static readonly UnitData CorsairHovercraft = new(
|
||||
Id: "CorsairHovercraft",
|
||||
Name: "Corsair Hovercraft",
|
||||
Hexite: 100,
|
||||
Flux: 62,
|
||||
Supply: 4,
|
||||
ProductionTime: 20,
|
||||
Health: 375,
|
||||
Energy: 0,
|
||||
Shields: 0,
|
||||
ArmorRating: 0,
|
||||
MovementSpeed: 550,
|
||||
DamagePerSecond: 20,
|
||||
AttackRange: 9,
|
||||
Attributes: ["Medium Armor"],
|
||||
Tier: 2,
|
||||
Faction: "Corsair",
|
||||
Hotkey: null,
|
||||
BuildAtSameTime: 2,
|
||||
Limit: null);
|
||||
|
||||
public static readonly UnitData CorsairFlamer = new(
|
||||
Id: "CorsairFlamer",
|
||||
Name: "Corsair Flamer",
|
||||
Hexite: 100,
|
||||
Flux: 200,
|
||||
Supply: 6,
|
||||
ProductionTime: 35,
|
||||
Health: 1000,
|
||||
Energy: 0,
|
||||
Shields: 0,
|
||||
ArmorRating: 2,
|
||||
MovementSpeed: 450,
|
||||
DamagePerSecond: 64,
|
||||
AttackRange: 4,
|
||||
Attributes: ["Heavy Armor", "Bonus Damage vs Light Armor", "Respawns for free after death"],
|
||||
Tier: 3,
|
||||
Faction: "Corsair",
|
||||
Hotkey: "V",
|
||||
BuildAtSameTime: 1,
|
||||
Limit: null);
|
||||
|
||||
public static readonly UnitData Gatherer = new(
|
||||
Id: "Gatherer",
|
||||
Name: "Gatherer",
|
||||
Hexite: 100,
|
||||
Flux: 0,
|
||||
Supply: 0,
|
||||
ProductionTime: 18,
|
||||
Health: 275,
|
||||
Energy: 0,
|
||||
Shields: 0,
|
||||
ArmorRating: 0,
|
||||
MovementSpeed: 510,
|
||||
DamagePerSecond: 0,
|
||||
AttackRange: 0,
|
||||
Attributes: ["Biological", "Respawns for free after death"],
|
||||
Tier: 0,
|
||||
Faction: "Grell",
|
||||
Hotkey: "H",
|
||||
BuildAtSameTime: 1,
|
||||
Limit: 5);
|
||||
|
||||
public static readonly UnitData BroodGuard = new(
|
||||
Id: "BroodGuard",
|
||||
Name: "Brood Guard",
|
||||
Hexite: 125,
|
||||
Flux: 0,
|
||||
Supply: 3,
|
||||
ProductionTime: 33,
|
||||
Health: 250,
|
||||
Energy: 0,
|
||||
Shields: 0,
|
||||
ArmorRating: 0,
|
||||
MovementSpeed: 550,
|
||||
DamagePerSecond: 15,
|
||||
AttackRange: 1,
|
||||
Attributes: ["Medium Armor", "Biological"],
|
||||
Tier: 1,
|
||||
Faction: "Grell",
|
||||
Hotkey: "G",
|
||||
BuildAtSameTime: 1,
|
||||
Limit: null);
|
||||
|
||||
public static readonly UnitData Stinger = new(
|
||||
Id: "Stinger",
|
||||
Name: "Stinger",
|
||||
Hexite: 25,
|
||||
Flux: 0,
|
||||
Supply: 1,
|
||||
ProductionTime: 39,
|
||||
Health: 90,
|
||||
Energy: 0,
|
||||
Shields: 0,
|
||||
ArmorRating: 0,
|
||||
MovementSpeed: 700,
|
||||
DamagePerSecond: 9,
|
||||
AttackRange: 1,
|
||||
Attributes: ["Light Armor", "Biological"],
|
||||
Tier: 1,
|
||||
Faction: "Grell",
|
||||
Hotkey: "Q",
|
||||
BuildAtSameTime: 3,
|
||||
Limit: null);
|
||||
|
||||
public static readonly UnitData Harbinger = new(
|
||||
Id: "Harbinger",
|
||||
Name: "Harbinger",
|
||||
Hexite: 75,
|
||||
Flux: 50,
|
||||
Supply: 4,
|
||||
ProductionTime: 35,
|
||||
Health: 400,
|
||||
Energy: 0,
|
||||
Shields: 0,
|
||||
ArmorRating: 1,
|
||||
MovementSpeed: 475,
|
||||
DamagePerSecond: 14,
|
||||
AttackRange: 6,
|
||||
Attributes: ["Biological", "Medium Armor", "Bonus Damage vs Heavy Armor"],
|
||||
Tier: 2,
|
||||
Faction: "Grell",
|
||||
Hotkey: "W",
|
||||
BuildAtSameTime: 1,
|
||||
Limit: null);
|
||||
|
||||
public static readonly UnitData Mandragora = new(
|
||||
Id: "Mandragora",
|
||||
Name: "Mandragora",
|
||||
Hexite: 125,
|
||||
Flux: 25,
|
||||
Supply: 4,
|
||||
ProductionTime: 48,
|
||||
Health: 450,
|
||||
Energy: 0,
|
||||
Shields: 0,
|
||||
ArmorRating: 1,
|
||||
MovementSpeed: 550,
|
||||
DamagePerSecond: 22,
|
||||
AttackRange: 4,
|
||||
Attributes: ["Medium Armor", "Biological"],
|
||||
Tier: 2,
|
||||
Faction: "Grell",
|
||||
Hotkey: "F",
|
||||
BuildAtSameTime: 1,
|
||||
Limit: null);
|
||||
|
||||
public static readonly UnitData Behemoth = new(
|
||||
Id: "Behemoth",
|
||||
Name: "Behemoth",
|
||||
Hexite: 100,
|
||||
Flux: 200,
|
||||
Supply: 8,
|
||||
ProductionTime: 80,
|
||||
Health: 625,
|
||||
Energy: 5,
|
||||
Shields: 0,
|
||||
ArmorRating: 1,
|
||||
MovementSpeed: 500,
|
||||
DamagePerSecond: 15,
|
||||
AttackRange: 12,
|
||||
Attributes: ["Air unit", "Regains Energy From Attacking", "Biological", "Heavy Armor", "Bonus Damage vs Heavy Armor"],
|
||||
Tier: 3,
|
||||
Faction: "Grell",
|
||||
Hotkey: "D",
|
||||
BuildAtSameTime: 1,
|
||||
Limit: null);
|
||||
|
||||
public static readonly UnitData Reaver = new(
|
||||
Id: "Reaver",
|
||||
Name: "Reaver",
|
||||
Hexite: 200,
|
||||
Flux: 100,
|
||||
Supply: 8,
|
||||
ProductionTime: 80,
|
||||
Health: 900,
|
||||
Energy: 15,
|
||||
Shields: 0,
|
||||
ArmorRating: 3,
|
||||
MovementSpeed: 525,
|
||||
DamagePerSecond: 51,
|
||||
AttackRange: 2,
|
||||
Attributes: ["Regains Energy From Attacking", "Biological", "Heavy Armor"],
|
||||
Tier: 3,
|
||||
Faction: "Grell",
|
||||
Hotkey: "A",
|
||||
BuildAtSameTime: 1,
|
||||
Limit: null);
|
||||
|
||||
public static readonly UnitData Weaver = new(
|
||||
Id: "Weaver",
|
||||
Name: "Weaver",
|
||||
Hexite: 75,
|
||||
Flux: 175,
|
||||
Supply: 4,
|
||||
ProductionTime: 40,
|
||||
Health: 200,
|
||||
Energy: 80,
|
||||
Shields: 0,
|
||||
ArmorRating: 0,
|
||||
MovementSpeed: 650,
|
||||
DamagePerSecond: 10,
|
||||
AttackRange: 12,
|
||||
Attributes: ["Medium Armor", "Biological", "Bonus Damage vs Air"],
|
||||
Tier: 3,
|
||||
Faction: "Grell",
|
||||
Hotkey: "E",
|
||||
BuildAtSameTime: 1,
|
||||
Limit: null);
|
||||
|
||||
public static IReadOnlyList<UnitData> All { get; } =
|
||||
[
|
||||
CorsairRaiders,
|
||||
CorsairRovers,
|
||||
CorsairSnipers,
|
||||
CorsairHovercraft,
|
||||
CorsairFlamer,
|
||||
Gatherer,
|
||||
BroodGuard,
|
||||
Stinger,
|
||||
Harbinger,
|
||||
Mandragora,
|
||||
Behemoth,
|
||||
Reaver,
|
||||
Weaver,
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,108 @@
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Diagnostics.HealthChecks;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using OpenTelemetry;
|
||||
using OpenTelemetry.Metrics;
|
||||
using OpenTelemetry.Trace;
|
||||
|
||||
namespace Microsoft.Extensions.Hosting;
|
||||
|
||||
// Adds common .NET Aspire service defaults: OpenTelemetry, health checks, resilience, and service discovery.
|
||||
public static class Extensions
|
||||
{
|
||||
public static TBuilder AddServiceDefaults<TBuilder>(this TBuilder builder) where TBuilder : IHostApplicationBuilder
|
||||
{
|
||||
builder.ConfigureOpenTelemetry();
|
||||
|
||||
builder.AddDefaultHealthChecks();
|
||||
|
||||
builder.Services.AddServiceDiscovery();
|
||||
|
||||
builder.Services.ConfigureHttpClientDefaults(http =>
|
||||
{
|
||||
// Turn on resilience by default
|
||||
http.AddStandardResilienceHandler();
|
||||
|
||||
// Turn on service discovery by default
|
||||
http.AddServiceDiscovery();
|
||||
});
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
public static TBuilder ConfigureOpenTelemetry<TBuilder>(this TBuilder builder) where TBuilder : IHostApplicationBuilder
|
||||
{
|
||||
builder.Logging.AddOpenTelemetry(logging =>
|
||||
{
|
||||
logging.IncludeFormattedMessage = true;
|
||||
logging.IncludeScopes = true;
|
||||
});
|
||||
|
||||
builder.Services.AddOpenTelemetry()
|
||||
.WithMetrics(metrics =>
|
||||
{
|
||||
metrics.AddAspNetCoreInstrumentation()
|
||||
.AddHttpClientInstrumentation()
|
||||
.AddRuntimeInstrumentation();
|
||||
})
|
||||
.WithTracing(tracing =>
|
||||
{
|
||||
tracing.AddAspNetCoreInstrumentation()
|
||||
.AddHttpClientInstrumentation();
|
||||
});
|
||||
|
||||
builder.AddOpenTelemetryExporters();
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
private static TBuilder AddOpenTelemetryExporters<TBuilder>(this TBuilder builder) where TBuilder : IHostApplicationBuilder
|
||||
{
|
||||
var useOtlpExporter = !string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]);
|
||||
|
||||
if (useOtlpExporter)
|
||||
{
|
||||
builder.Services.AddOpenTelemetry().UseOtlpExporter();
|
||||
}
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
public static TBuilder AddDefaultHealthChecks<TBuilder>(this TBuilder builder) where TBuilder : IHostApplicationBuilder
|
||||
{
|
||||
builder.Services.AddHealthChecks()
|
||||
// Add a default liveness check to ensure app is responsive
|
||||
.AddCheck("self", () => HealthCheckResult.Healthy(), ["live"]);
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
public static WebApplication MapDefaultEndpoints(this WebApplication app)
|
||||
{
|
||||
// Adding health checks endpoints to applications in non-development environments has security implications.
|
||||
// See https://aka.ms/dotnet/aspire/healthchecks for details before enabling these endpoints in other environments.
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
// All health checks must pass for app to be considered ready to accept traffic after starting
|
||||
app.MapHealthChecks("/health");
|
||||
|
||||
// Only health checks tagged with the "live" tag must pass for app to be considered alive
|
||||
app.MapHealthChecks("/alive", new HealthCheckOptions
|
||||
{
|
||||
Predicate = r => r.Tags.Contains("live")
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
app.MapHealthChecks("/health");
|
||||
app.MapHealthChecks("/alive", new HealthCheckOptions
|
||||
{
|
||||
Predicate = r => r.Tags.Contains("live")
|
||||
});
|
||||
}
|
||||
|
||||
return app;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsAspireSharedProject>true</IsAspireSharedProject>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
|
||||
<PackageReference Include="Microsoft.Extensions.Http.Resilience" Version="10.10.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.ServiceDiscovery" Version="10.10.0" />
|
||||
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.19.1" />
|
||||
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.19.1" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.11.1" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.11.1" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.11.1" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,6 @@
|
||||
<Router AppAssembly="@typeof(App).Assembly" NotFoundPage="typeof(Pages.NotFound)">
|
||||
<Found Context="routeData">
|
||||
<RouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)"/>
|
||||
<FocusOnNavigate RouteData="@routeData" Selector="h1"/>
|
||||
</Found>
|
||||
</Router>
|
||||
@@ -0,0 +1,20 @@
|
||||
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
||||
WORKDIR /src
|
||||
|
||||
# Copy project files for caching restore layer
|
||||
COPY ["ZeroSpace.Web/ZeroSpace.Web.csproj", "ZeroSpace.Web/"]
|
||||
COPY ["ZeroSpace.Model/ZeroSpace.Model.csproj", "ZeroSpace.Model/"]
|
||||
|
||||
RUN dotnet restore "ZeroSpace.Web/ZeroSpace.Web.csproj"
|
||||
|
||||
# Copy source code and build
|
||||
COPY . .
|
||||
WORKDIR "/src/ZeroSpace.Web"
|
||||
RUN dotnet publish "ZeroSpace.Web.csproj" -c Release -o /app/publish
|
||||
|
||||
FROM nginx:alpine AS final
|
||||
WORKDIR /usr/share/nginx/html
|
||||
COPY --from=build /app/publish/wwwroot .
|
||||
COPY ZeroSpace.Web/nginx.conf /etc/nginx/nginx.conf
|
||||
EXPOSE 80
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
@@ -0,0 +1,15 @@
|
||||
@using Telerik.Blazor.Components
|
||||
@inherits LayoutComponentBase
|
||||
<div class="page">
|
||||
<div class="sidebar">
|
||||
<NavMenu/>
|
||||
</div>
|
||||
|
||||
<main>
|
||||
<article class="content px-4">
|
||||
<TelerikRootComponent>
|
||||
@Body
|
||||
</TelerikRootComponent>
|
||||
</article>
|
||||
</main>
|
||||
</div>
|
||||
@@ -0,0 +1,77 @@
|
||||
.page {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
main {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
background-image: linear-gradient(180deg, #0d1117 0%, #161b22 70%);
|
||||
}
|
||||
|
||||
.top-row {
|
||||
background-color: #f7f7f7;
|
||||
border-bottom: 1px solid #d6d5d5;
|
||||
justify-content: flex-end;
|
||||
height: 3.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.top-row ::deep a, .top-row ::deep .btn-link {
|
||||
white-space: nowrap;
|
||||
margin-left: 1.5rem;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.top-row ::deep a:hover, .top-row ::deep .btn-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.top-row ::deep a:first-child {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
@media (max-width: 640.98px) {
|
||||
.top-row {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.top-row ::deep a, .top-row ::deep .btn-link {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 641px) {
|
||||
.page {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 250px;
|
||||
height: 100vh;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.top-row {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.top-row.auth ::deep a:first-child {
|
||||
flex: 1;
|
||||
text-align: right;
|
||||
width: 0;
|
||||
}
|
||||
|
||||
.top-row, article {
|
||||
padding-left: 2rem !important;
|
||||
padding-right: 1.5rem !important;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<div class="top-row ps-3 navbar navbar-dark">
|
||||
<div class="container-fluid">
|
||||
<a class="navbar-brand" href="">ZeroSpace</a>
|
||||
<button title="Navigation menu" class="navbar-toggler" @onclick="ToggleNavMenu">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="@NavMenuCssClass nav-scrollable" @onclick="ToggleNavMenu">
|
||||
<nav class="nav flex-column">
|
||||
<div class="nav-item px-3">
|
||||
<NavLink class="nav-link" href="" Match="NavLinkMatch.All">
|
||||
<span class="bi bi-house-door-fill-nav-menu" aria-hidden="true"></span> Home
|
||||
</NavLink>
|
||||
</div>
|
||||
<div class="nav-item px-3">
|
||||
<NavLink class="nav-link" href="units">
|
||||
<span class="bi bi-list-nested-nav-menu" aria-hidden="true"></span> Units
|
||||
</NavLink>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private bool collapseNavMenu = true;
|
||||
|
||||
private string? NavMenuCssClass => collapseNavMenu ? "collapse" : null;
|
||||
|
||||
private void ToggleNavMenu()
|
||||
{
|
||||
collapseNavMenu = !collapseNavMenu;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
.navbar-toggler {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.top-row {
|
||||
min-height: 3.5rem;
|
||||
background-color: rgba(0,0,0,0.4);
|
||||
}
|
||||
|
||||
.navbar-brand {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.bi {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
margin-right: 0.75rem;
|
||||
top: -1px;
|
||||
background-size: cover;
|
||||
}
|
||||
|
||||
.bi-house-door-fill-nav-menu {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-house-door-fill' viewBox='0 0 16 16'%3E%3Cpath d='M6.5 14.5v-3.505c0-.245.25-.495.5-.495h2c.25 0 .5.25.5.5v3.5a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5v-7a.5.5 0 0 0-.146-.354L13 5.793V2.5a.5.5 0 0 0-.5-.5h-1a.5.5 0 0 0-.5.5v1.293L8.354 1.146a.5.5 0 0 0-.708 0l-6 6A.5.5 0 0 0 1.5 7.5v7a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5Z'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
.bi-plus-square-fill-nav-menu {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-plus-square-fill' viewBox='0 0 16 16'%3E%3Cpath d='M2 0a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2H2zm6.5 4.5v3h3a.5.5 0 0 1 0 1h-3v3a.5.5 0 0 1-1 0v-3h-3a.5.5 0 0 1 0-1h3v-3a.5.5 0 0 1 1 0z'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
.bi-list-nested-nav-menu {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-list-nested' viewBox='0 0 16 16'%3E%3Cpath fill-rule='evenodd' d='M4.5 11.5A.5.5 0 0 1 5 11h10a.5.5 0 0 1 0 1H5a.5.5 0 0 1-.5-.5zm-2-4A.5.5 0 0 1 3 7h10a.5.5 0 0 1 0 1H3a.5.5 0 0 1-.5-.5zm-2-4A.5.5 0 0 1 1 3h10a.5.5 0 0 1 0 1H1a.5.5 0 0 1-.5-.5z'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
font-size: 0.9rem;
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.nav-item:first-of-type {
|
||||
padding-top: 1rem;
|
||||
}
|
||||
|
||||
.nav-item:last-of-type {
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
|
||||
.nav-item ::deep a {
|
||||
color: #d7d7d7;
|
||||
border-radius: 4px;
|
||||
height: 3rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
line-height: 3rem;
|
||||
}
|
||||
|
||||
.nav-item ::deep a.active {
|
||||
background-color: rgba(88, 166, 255, 0.2);
|
||||
color: #58a6ff;
|
||||
}
|
||||
|
||||
.nav-item ::deep a:hover {
|
||||
background-color: rgba(88, 166, 255, 0.1);
|
||||
color: #58a6ff;
|
||||
}
|
||||
|
||||
@media (min-width: 641px) {
|
||||
.navbar-toggler {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.collapse {
|
||||
/* Never collapse the sidebar for wide screens */
|
||||
display: block;
|
||||
}
|
||||
|
||||
.nav-scrollable {
|
||||
/* Allow sidebar to scroll for tall menus */
|
||||
height: calc(100vh - 3.5rem);
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
@page "/"
|
||||
|
||||
<PageTitle>Home</PageTitle>
|
||||
|
||||
<h1>Hello, world!</h1>
|
||||
|
||||
Welcome to your new app.
|
||||
@@ -0,0 +1,5 @@
|
||||
@page "/not-found"
|
||||
@layout MainLayout
|
||||
|
||||
<h3>Not Found</h3>
|
||||
<p>Sorry, the content you are looking for does not exist.</p>
|
||||
@@ -0,0 +1,100 @@
|
||||
@page "/units"
|
||||
@using ZeroSpace.Model
|
||||
@using ZeroSpace.Web.Services
|
||||
@using Telerik.Blazor
|
||||
@using Telerik.Blazor.Components
|
||||
@inject IUnitsApiClient UnitsApiClient
|
||||
|
||||
<PageTitle>Units</PageTitle>
|
||||
|
||||
<div class="section-header d-flex align-items-center mb-4">
|
||||
<h1 class="mb-0">Units</h1>
|
||||
<div class="ms-3 flex-grow-1 border-bottom opacity-25"></div>
|
||||
</div>
|
||||
|
||||
@if (isLoading)
|
||||
{
|
||||
<div class="d-flex justify-content-center py-5">
|
||||
<div class="spinner-border text-success" role="status">
|
||||
<span class="visually-hidden">Loading units...</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else if (errorMessage != null)
|
||||
{
|
||||
<div class="alert alert-danger d-flex align-items-center justify-content-between" role="alert">
|
||||
<div>
|
||||
<strong>Error:</strong> @errorMessage
|
||||
</div>
|
||||
<button class="btn btn-outline-danger btn-sm" @onclick="LoadUnitsAsync">Retry</button>
|
||||
</div>
|
||||
}
|
||||
else if (gearNotes == null || gearNotes.Count == 0)
|
||||
{
|
||||
<div class="alert alert-warning" role="alert">
|
||||
No units found.
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="grid-container">
|
||||
<TelerikGrid Data="@gearNotes" Pageable="true" PageSize="50" Sortable="true" FilterMode="@GridFilterMode.FilterRow"
|
||||
Height="calc(100vh - 250px)">
|
||||
<GridColumns>
|
||||
<GridColumn Field="@(nameof(UnitData.Name))" Title="Name" Width="200px"/>
|
||||
<GridColumn Field="@(nameof(UnitData.Hexite))" Title="Hexite" Width="90px" />
|
||||
<GridColumn Field="@(nameof(UnitData.Flux))" Title="Flux" Width="90px" />
|
||||
|
||||
<GridColumn Field="@(nameof(UnitData.DpsPerTotalCost))" Title="DpsPerTotalCost" Width="90px" />
|
||||
|
||||
<GridColumn Field="@(nameof(UnitData.HealthPerTotalCost))" Title="HealthPerTotalCost" Width="90px" />
|
||||
|
||||
<GridColumn Field="@(nameof(UnitData.Supply))" Title="Supply" Width="90px" />
|
||||
<GridColumn Field="@(nameof(UnitData.ProductionTime))" Title="Production Time" Width="90px" />
|
||||
<GridColumn Field="@(nameof(UnitData.Health))" Title="Health" Width="90px" />
|
||||
<GridColumn Field="@(nameof(UnitData.Shields))" Title="Shields" Width="90px" />
|
||||
<GridColumn Field="@(nameof(UnitData.ArmorRating))" Title="Armor Rating" Width="90px" />
|
||||
<GridColumn Field="@(nameof(UnitData.MovementSpeed))" Title="Movement Speed" Width="90px" />
|
||||
<GridColumn Field="@(nameof(UnitData.DamagePerSecond))" Title="Damage Per Second" Width="90px" />
|
||||
<GridColumn Field="@(nameof(UnitData.AttackRange))" Title="Attack Range" Width="90px" />
|
||||
<GridColumn Field="@(nameof(UnitData.Attributes))" Title="Attributes" Width="90px" />
|
||||
<GridColumn Field="@(nameof(UnitData.Tier))" Title="Tier" Width="90px" />
|
||||
<GridColumn Field="@(nameof(UnitData.Faction))" Title="Faction" Width="90px" />
|
||||
<GridColumn Field="@(nameof(UnitData.Hotkey))" Title="Hotkey" Width="90px" />
|
||||
<GridColumn Field="@(nameof(UnitData.BuildAtSameTime))" Title="Build At Same Time" Width="90px" />
|
||||
<GridColumn Field="@(nameof(UnitData.Limit))" Title="Limit" Width="90px" />
|
||||
</GridColumns>
|
||||
</TelerikGrid>
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
private List<UnitData>? gearNotes;
|
||||
private bool isLoading = true;
|
||||
private string? errorMessage;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await LoadUnitsAsync();
|
||||
}
|
||||
|
||||
private async Task LoadUnitsAsync()
|
||||
{
|
||||
isLoading = true;
|
||||
errorMessage = null;
|
||||
|
||||
try
|
||||
{
|
||||
var units = await UnitsApiClient.GetUnitsAsync();
|
||||
gearNotes = units.ToList();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errorMessage = $"Failed to load units: {ex.Message}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using Microsoft.AspNetCore.Components.Web;
|
||||
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
|
||||
using ZeroSpace.Web;
|
||||
using ZeroSpace.Web.Services;
|
||||
|
||||
var builder = WebAssemblyHostBuilder.CreateDefault(args);
|
||||
builder.RootComponents.Add<App>("#app");
|
||||
builder.RootComponents.Add<HeadOutlet>("head::after");
|
||||
|
||||
builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) });
|
||||
|
||||
builder.Services.AddHttpClient<IUnitsApiClient, UnitsApiClient>(client =>
|
||||
{
|
||||
var apiBaseUrl = builder.Configuration["services:apiservice:https:0"]
|
||||
?? builder.Configuration["services:apiservice:http:0"]
|
||||
?? builder.Configuration["ApiServiceUrl"]
|
||||
?? builder.HostEnvironment.BaseAddress;
|
||||
|
||||
if (!apiBaseUrl.EndsWith('/'))
|
||||
{
|
||||
apiBaseUrl += "/";
|
||||
}
|
||||
|
||||
client.BaseAddress = new Uri(apiBaseUrl);
|
||||
});
|
||||
|
||||
builder.Services.AddTelerikBlazor();
|
||||
|
||||
await builder.Build().RunAsync();
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/launchsettings.json",
|
||||
"profiles": {
|
||||
"http": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": true,
|
||||
"inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}",
|
||||
"applicationUrl": "http://localhost:5114",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
},
|
||||
"https": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": true,
|
||||
"inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}",
|
||||
"applicationUrl": "https://localhost:7205;http://localhost:5114",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
using ZeroSpace.Model;
|
||||
|
||||
namespace ZeroSpace.Web.Services;
|
||||
|
||||
public interface IUnitsApiClient
|
||||
{
|
||||
Task<IReadOnlyList<UnitData>> GetUnitsAsync(string? faction = null, int? tier = null, CancellationToken cancellationToken = default);
|
||||
Task<UnitData?> GetUnitByIdAsync(string id, CancellationToken cancellationToken = default);
|
||||
Task<UnitData?> CreateUnitAsync(UnitData unit, CancellationToken cancellationToken = default);
|
||||
Task<bool> DeleteUnitAsync(string id, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ZeroSpace.Model;
|
||||
|
||||
namespace ZeroSpace.Web.Services;
|
||||
|
||||
public class UnitsApiClient : IUnitsApiClient
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly ILogger<UnitsApiClient> _logger;
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
private const string AllUnitFields = @"
|
||||
id
|
||||
name
|
||||
hexite
|
||||
flux
|
||||
supply
|
||||
productionTime
|
||||
health
|
||||
energy
|
||||
shields
|
||||
armorRating
|
||||
movementSpeed
|
||||
damagePerSecond
|
||||
attackRange
|
||||
attributes
|
||||
tier
|
||||
faction
|
||||
hotkey
|
||||
buildAtSameTime
|
||||
limit";
|
||||
|
||||
public UnitsApiClient(HttpClient httpClient, ILogger<UnitsApiClient> logger)
|
||||
{
|
||||
_httpClient = httpClient;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<UnitData>> GetUnitsAsync(string? faction = null, int? tier = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
Dictionary<string, object?>? whereFilter = null;
|
||||
if (!string.IsNullOrWhiteSpace(faction) || tier.HasValue)
|
||||
{
|
||||
whereFilter = new Dictionary<string, object?>();
|
||||
if (!string.IsNullOrWhiteSpace(faction))
|
||||
{
|
||||
whereFilter["faction"] = new Dictionary<string, object?> { ["eq"] = faction };
|
||||
}
|
||||
if (tier.HasValue)
|
||||
{
|
||||
whereFilter["tier"] = new Dictionary<string, object?> { ["eq"] = tier.Value };
|
||||
}
|
||||
}
|
||||
|
||||
var query = $@"
|
||||
query GetUnits($where: UnitDataFilterInput) {{
|
||||
units(where: $where) {{
|
||||
{AllUnitFields}
|
||||
}}
|
||||
}}";
|
||||
|
||||
var requestBody = new
|
||||
{
|
||||
query,
|
||||
variables = new
|
||||
{
|
||||
where = whereFilter
|
||||
}
|
||||
};
|
||||
|
||||
var response = await _httpClient.PostAsJsonAsync("graphql", requestBody, cancellationToken);
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
var result = await response.Content.ReadFromJsonAsync<GraphQLResponse<UnitsQueryResult>>(JsonOptions, cancellationToken);
|
||||
if (result?.Data?.Units is not null && result.Data.Units.Count > 0)
|
||||
{
|
||||
return result.Data.Units;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to load units via GraphQL API service. Falling back to static units list.");
|
||||
}
|
||||
|
||||
// Fallback to static model data if GraphQL call fails
|
||||
var fallback = Units.All.AsEnumerable();
|
||||
if (!string.IsNullOrWhiteSpace(faction))
|
||||
{
|
||||
fallback = fallback.Where(u => u.Faction.Equals(faction, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
if (tier.HasValue)
|
||||
{
|
||||
fallback = fallback.Where(u => u.Tier == tier.Value);
|
||||
}
|
||||
|
||||
return fallback.ToList();
|
||||
}
|
||||
|
||||
public async Task<UnitData?> GetUnitByIdAsync(string id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var query = $@"
|
||||
query GetUnitById($id: String!) {{
|
||||
unitById(id: $id) {{
|
||||
{AllUnitFields}
|
||||
}}
|
||||
}}";
|
||||
|
||||
var requestBody = new
|
||||
{
|
||||
query,
|
||||
variables = new
|
||||
{
|
||||
id
|
||||
}
|
||||
};
|
||||
|
||||
var response = await _httpClient.PostAsJsonAsync("graphql", requestBody, cancellationToken);
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
var result = await response.Content.ReadFromJsonAsync<GraphQLResponse<UnitByIdQueryResult>>(JsonOptions, cancellationToken);
|
||||
if (result?.Data?.UnitById is not null)
|
||||
{
|
||||
return result.Data.UnitById;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to get unit '{Id}' via GraphQL API.", id);
|
||||
}
|
||||
|
||||
return Units.All.FirstOrDefault(u => u.Id == id);
|
||||
}
|
||||
|
||||
public async Task<UnitData?> CreateUnitAsync(UnitData unit, CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var mutation = $@"
|
||||
mutation CreateUnit($input: CreateUnitInput!) {{
|
||||
createUnit(input: $input) {{
|
||||
{AllUnitFields}
|
||||
}}
|
||||
}}";
|
||||
|
||||
var input = new
|
||||
{
|
||||
id = unit.Id,
|
||||
name = unit.Name,
|
||||
hexite = unit.Hexite,
|
||||
flux = unit.Flux,
|
||||
supply = unit.Supply,
|
||||
productionTime = unit.ProductionTime,
|
||||
health = unit.Health,
|
||||
energy = unit.Energy,
|
||||
shields = unit.Shields,
|
||||
armorRating = unit.ArmorRating,
|
||||
movementSpeed = unit.MovementSpeed,
|
||||
damagePerSecond = unit.DamagePerSecond,
|
||||
attackRange = unit.AttackRange,
|
||||
attributes = unit.Attributes,
|
||||
tier = unit.Tier,
|
||||
faction = unit.Faction,
|
||||
hotkey = unit.Hotkey,
|
||||
buildAtSameTime = unit.BuildAtSameTime,
|
||||
limit = unit.Limit
|
||||
};
|
||||
|
||||
var requestBody = new
|
||||
{
|
||||
query = mutation,
|
||||
variables = new
|
||||
{
|
||||
input
|
||||
}
|
||||
};
|
||||
|
||||
var response = await _httpClient.PostAsJsonAsync("graphql", requestBody, cancellationToken);
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
var result = await response.Content.ReadFromJsonAsync<GraphQLResponse<CreateUnitMutationResult>>(JsonOptions, cancellationToken);
|
||||
return result?.Data?.CreateUnit;
|
||||
}
|
||||
|
||||
_logger.LogError("Failed to create unit via GraphQL. Status: {StatusCode}", response.StatusCode);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error creating unit via GraphQL.");
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteUnitAsync(string id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var mutation = @"
|
||||
mutation DeleteUnit($id: String!) {
|
||||
deleteUnit(id: $id)
|
||||
}";
|
||||
|
||||
var requestBody = new
|
||||
{
|
||||
query = mutation,
|
||||
variables = new
|
||||
{
|
||||
id
|
||||
}
|
||||
};
|
||||
|
||||
var response = await _httpClient.PostAsJsonAsync("graphql", requestBody, cancellationToken);
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
var result = await response.Content.ReadFromJsonAsync<GraphQLResponse<DeleteUnitMutationResult>>(JsonOptions, cancellationToken);
|
||||
return result?.Data?.DeleteUnit ?? false;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error deleting unit '{Id}' via GraphQL.", id);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private sealed class GraphQLResponse<T>
|
||||
{
|
||||
[JsonPropertyName("data")]
|
||||
public T? Data { get; set; }
|
||||
|
||||
[JsonPropertyName("errors")]
|
||||
public List<GraphQLErrorItem>? Errors { get; set; }
|
||||
}
|
||||
|
||||
private sealed class GraphQLErrorItem
|
||||
{
|
||||
[JsonPropertyName("message")]
|
||||
public string Message { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
private sealed class UnitsQueryResult
|
||||
{
|
||||
[JsonPropertyName("units")]
|
||||
public List<UnitData>? Units { get; set; }
|
||||
}
|
||||
|
||||
private sealed class UnitByIdQueryResult
|
||||
{
|
||||
[JsonPropertyName("unitById")]
|
||||
public UnitData? UnitById { get; set; }
|
||||
}
|
||||
|
||||
private sealed class CreateUnitMutationResult
|
||||
{
|
||||
[JsonPropertyName("createUnit")]
|
||||
public UnitData? CreateUnit { get; set; }
|
||||
}
|
||||
|
||||
private sealed class DeleteUnitMutationResult
|
||||
{
|
||||
[JsonPropertyName("deleteUnit")]
|
||||
public bool DeleteUnit { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.BlazorWebAssembly">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<OverrideHtmlAssetPlaceholders>true</OverrideHtmlAssetPlaceholders>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="10.0.9"/>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="10.0.9" PrivateAssets="all"/>
|
||||
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.12" />
|
||||
<PackageReference Include="Telerik.UI.for.Blazor" Version="14.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\ZeroSpace.Model\ZeroSpace.Model.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,11 @@
|
||||
@using System.Net.Http
|
||||
@using System.Net.Http.Json
|
||||
@using Microsoft.AspNetCore.Components.Forms
|
||||
@using Microsoft.AspNetCore.Components.Routing
|
||||
@using Microsoft.AspNetCore.Components.Web
|
||||
@using Microsoft.AspNetCore.Components.Web.Virtualization
|
||||
@using Microsoft.AspNetCore.Components.WebAssembly.Http
|
||||
@using Microsoft.JSInterop
|
||||
@using ZeroSpace.Web
|
||||
@using ZeroSpace.Web.Layout
|
||||
@using ZeroSpace.Model
|
||||
@@ -0,0 +1,27 @@
|
||||
events { }
|
||||
http {
|
||||
include mime.types;
|
||||
types {
|
||||
application/wasm wasm;
|
||||
}
|
||||
default_type application/octet-stream;
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
|
||||
location / {
|
||||
root /usr/share/nginx/html;
|
||||
index index.html index.htm;
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Cache static assets
|
||||
location ~* \.(?:ico|css|js|gif|jpe?g|png|wasm|woff2?|eot|ttf|svg)$ {
|
||||
root /usr/share/nginx/html;
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, max-age=31536000, immutable";
|
||||
try_files $uri =404;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"ApiServiceUrl": "https://localhost:7142"
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"ApiServiceUrl": "https://localhost:7142"
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
html, body {
|
||||
font-family: 'Segoe UI', 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||
background-color: #0d1117;
|
||||
color: #c9d1d9;
|
||||
}
|
||||
|
||||
h1:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
color: #e6edf3;
|
||||
}
|
||||
|
||||
a, .btn-link {
|
||||
color: #58a6ff;
|
||||
}
|
||||
|
||||
p {
|
||||
color: #8b949e;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
color: #fff;
|
||||
background-color: #1b6ec2;
|
||||
border-color: #1861ac;
|
||||
}
|
||||
|
||||
.btn:focus, .btn:active:focus, .btn-link.nav-link:focus, .form-control:focus, .form-check-input:focus {
|
||||
box-shadow: 0 0 0 0.1rem white, 0 0 0 0.25rem #258cfb;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding-top: 1.1rem;
|
||||
}
|
||||
|
||||
.valid.modified:not([type=checkbox]) {
|
||||
outline: 1px solid #26b050;
|
||||
}
|
||||
|
||||
.invalid {
|
||||
outline: 1px solid red;
|
||||
}
|
||||
|
||||
.validation-message {
|
||||
color: red;
|
||||
}
|
||||
|
||||
#blazor-error-ui {
|
||||
color-scheme: light only;
|
||||
background: lightyellow;
|
||||
bottom: 0;
|
||||
box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2);
|
||||
box-sizing: border-box;
|
||||
display: none;
|
||||
left: 0;
|
||||
padding: 0.6rem 1.25rem 0.7rem 1.25rem;
|
||||
position: fixed;
|
||||
width: 100%;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
#blazor-error-ui .dismiss {
|
||||
cursor: pointer;
|
||||
position: absolute;
|
||||
right: 0.75rem;
|
||||
top: 0.5rem;
|
||||
}
|
||||
|
||||
.blazor-error-boundary {
|
||||
background: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTYiIGhlaWdodD0iNDkiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIG92ZXJmbG93PSJoaWRkZW4iPjxkZWZzPjxjbGlwUGF0aCBpZD0iY2xpcDAiPjxyZWN0IHg9IjIzNSIgeT0iNTEiIHdpZHRoPSI1NiIgaGVpZ2h0PSI0OSIvPjwvY2xpcFBhdGg+PC9kZWZzPjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMCkiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0yMzUgLTUxKSI+PHBhdGggZD0iTTI2My41MDYgNTFDMjY0LjcxNyA1MSAyNjUuODEzIDUxLjQ4MzcgMjY2LjYwNiA1Mi4yNjU4TDI2Ny4wNTIgNTIuNzk4NyAyNjcuNTM5IDUzLjYyODMgMjkwLjE4NSA5Mi4xODMxIDI5MC41NDUgOTIuNzk1IDI5MC42NTYgOTIuOTk2QzI5MC44NzcgOTMuNTEzIDI5MSA5NC4wODE1IDI5MSA5NC42NzgyIDI5MSA5Ny4wNjUxIDI4OS4wMzggOTkgMjg2LjYxNyA5OUwyNDAuMzgzIDk5QzIzNy45NjMgOTkgMjM2IDk3LjA2NTEgMjM2IDk0LjY3ODIgMjM2IDk0LjM3OTkgMjM2LjAzMSA5NC4wODg2IDIzNi4wODkgOTMuODA3MkwyMzYuMzM4IDkzLjAxNjIgMjM2Ljg1OCA5Mi4xMzE0IDI1OS40NzMgNTMuNjI5NCAyNTkuOTYxIDUyLjc5ODUgMjYwLjQwNyA1Mi4yNjU4QzI2MS4yIDUxLjQ4MzcgMjYyLjI5NiA1MSAyNjMuNTA2IDUxWk0yNjMuNTg2IDY2LjAxODNDMjYwLjczNyA2Ni4wMTgzIDI1OS4zMTMgNjcuMTI0NSAyNTkuMzEzIDY5LjMzNyAyNTkuMzEzIDY5LjYxMDIgMjU5LjMzMiA2OS44NjA4IDI1OS4zNzEgNzAuMDg4N0wyNjEuNzk1IDg0LjAxNjEgMjY1LjM4IDg0LjAxNjEgMjY3LjgyMSA2OS43NDc1QzI2Ny44NiA2OS43MzA5IDI2Ny44NzkgNjkuNTg3NyAyNjcuODc5IDY5LjMxNzkgMjY3Ljg3OSA2Ny4xMTgyIDI2Ni40NDggNjYuMDE4MyAyNjMuNTg2IDY2LjAxODNaTTI2My41NzYgODYuMDU0N0MyNjEuMDQ5IDg2LjA1NDcgMjU5Ljc4NiA4Ny4zMDA1IDI1OS43ODYgODkuNzkyMSAyNTkuNzg2IDkyLjI4MzcgMjYxLjA0OSA5My41Mjk1IDI2My41NzYgOTMuNTI5NSAyNjYuMTE2IDkzLjUyOTUgMjY3LjM4NyA5Mi4yODM3IDI2Ny4zODcgODkuNzkyMSAyNjcuMzg3IDg3LjMwMDUgMjY2LjExNiA4Ni4wNTQ3IDI2My41NzYgODYuMDU0N1oiIGZpbGw9IiNGRkU1MDAiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPjwvZz48L3N2Zz4=) no-repeat 1rem/1.8rem, #b32121;
|
||||
padding: 1rem 1rem 1rem 3.7rem;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.blazor-error-boundary::after {
|
||||
content: "An error has occurred."
|
||||
}
|
||||
|
||||
.loading-progress {
|
||||
position: absolute;
|
||||
display: block;
|
||||
width: 8rem;
|
||||
height: 8rem;
|
||||
inset: 20vh 0 auto 0;
|
||||
margin: 0 auto 0 auto;
|
||||
}
|
||||
|
||||
.loading-progress circle {
|
||||
fill: none;
|
||||
stroke: #e0e0e0;
|
||||
stroke-width: 0.6rem;
|
||||
transform-origin: 50% 50%;
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
.loading-progress circle:last-child {
|
||||
stroke: #1b6ec2;
|
||||
stroke-dasharray: calc(3.141 * var(--blazor-load-percentage, 0%) * 0.8), 500%;
|
||||
transition: stroke-dasharray 0.05s ease-in-out;
|
||||
}
|
||||
|
||||
.loading-progress-text {
|
||||
position: absolute;
|
||||
text-align: center;
|
||||
font-weight: bold;
|
||||
inset: calc(20vh + 3.25rem) 0 auto 0.2rem;
|
||||
}
|
||||
|
||||
.loading-progress-text:after {
|
||||
content: var(--blazor-load-percentage-text, "Loading");
|
||||
}
|
||||
|
||||
code {
|
||||
color: #c02d76;
|
||||
}
|
||||
|
||||
.form-floating > .form-control-plaintext::placeholder, .form-floating > .form-control::placeholder {
|
||||
color: var(--bs-secondary-color);
|
||||
text-align: end;
|
||||
}
|
||||
|
||||
.form-floating > .form-control-plaintext:focus::placeholder, .form-floating > .form-control:focus::placeholder {
|
||||
text-align: start;
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.1 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.6 KiB |
@@ -0,0 +1,36 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Web</title>
|
||||
<base href="/" />
|
||||
<link rel="preload" id="webassembly" />
|
||||
<link rel="stylesheet" href="lib/bootstrap/dist/css/bootstrap.min.css" />
|
||||
<link rel="stylesheet" href="css/app.css" />
|
||||
<link rel="icon" type="image/png" href="favicon.png" />
|
||||
<link href="_content/Telerik.UI.for.Blazor/css/kendo-theme-default/all.css" rel="stylesheet"/>
|
||||
<link href="ZeroSpace.Web.styles.css" rel="stylesheet" />
|
||||
<script type="importmap"></script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="app">
|
||||
<svg class="loading-progress">
|
||||
<circle r="40%" cx="50%" cy="50%" />
|
||||
<circle r="40%" cx="50%" cy="50%" />
|
||||
</svg>
|
||||
<div class="loading-progress-text"></div>
|
||||
</div>
|
||||
|
||||
<div id="blazor-error-ui">
|
||||
An unhandled error has occurred.
|
||||
<a href="." class="reload">Reload</a>
|
||||
<span class="dismiss">🗙</span>
|
||||
</div>
|
||||
<script defer src="_content/Telerik.UI.for.Blazor/js/telerik-blazor.js"></script>
|
||||
<script src="_framework/blazor.webassembly#[.{fingerprint}].js"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
+4084
File diff suppressed because it is too large
Load Diff
+1
File diff suppressed because one or more lines are too long
+6
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
@@ -0,0 +1,597 @@
|
||||
/*!
|
||||
* Bootstrap Reboot v5.3.3 (https://getbootstrap.com/)
|
||||
* Copyright 2011-2024 The Bootstrap Authors
|
||||
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
|
||||
*/
|
||||
:root,
|
||||
[data-bs-theme=light] {
|
||||
--bs-blue: #0d6efd;
|
||||
--bs-indigo: #6610f2;
|
||||
--bs-purple: #6f42c1;
|
||||
--bs-pink: #d63384;
|
||||
--bs-red: #dc3545;
|
||||
--bs-orange: #fd7e14;
|
||||
--bs-yellow: #ffc107;
|
||||
--bs-green: #198754;
|
||||
--bs-teal: #20c997;
|
||||
--bs-cyan: #0dcaf0;
|
||||
--bs-black: #000;
|
||||
--bs-white: #fff;
|
||||
--bs-gray: #6c757d;
|
||||
--bs-gray-dark: #343a40;
|
||||
--bs-gray-100: #f8f9fa;
|
||||
--bs-gray-200: #e9ecef;
|
||||
--bs-gray-300: #dee2e6;
|
||||
--bs-gray-400: #ced4da;
|
||||
--bs-gray-500: #adb5bd;
|
||||
--bs-gray-600: #6c757d;
|
||||
--bs-gray-700: #495057;
|
||||
--bs-gray-800: #343a40;
|
||||
--bs-gray-900: #212529;
|
||||
--bs-primary: #0d6efd;
|
||||
--bs-secondary: #6c757d;
|
||||
--bs-success: #198754;
|
||||
--bs-info: #0dcaf0;
|
||||
--bs-warning: #ffc107;
|
||||
--bs-danger: #dc3545;
|
||||
--bs-light: #f8f9fa;
|
||||
--bs-dark: #212529;
|
||||
--bs-primary-rgb: 13, 110, 253;
|
||||
--bs-secondary-rgb: 108, 117, 125;
|
||||
--bs-success-rgb: 25, 135, 84;
|
||||
--bs-info-rgb: 13, 202, 240;
|
||||
--bs-warning-rgb: 255, 193, 7;
|
||||
--bs-danger-rgb: 220, 53, 69;
|
||||
--bs-light-rgb: 248, 249, 250;
|
||||
--bs-dark-rgb: 33, 37, 41;
|
||||
--bs-primary-text-emphasis: #052c65;
|
||||
--bs-secondary-text-emphasis: #2b2f32;
|
||||
--bs-success-text-emphasis: #0a3622;
|
||||
--bs-info-text-emphasis: #055160;
|
||||
--bs-warning-text-emphasis: #664d03;
|
||||
--bs-danger-text-emphasis: #58151c;
|
||||
--bs-light-text-emphasis: #495057;
|
||||
--bs-dark-text-emphasis: #495057;
|
||||
--bs-primary-bg-subtle: #cfe2ff;
|
||||
--bs-secondary-bg-subtle: #e2e3e5;
|
||||
--bs-success-bg-subtle: #d1e7dd;
|
||||
--bs-info-bg-subtle: #cff4fc;
|
||||
--bs-warning-bg-subtle: #fff3cd;
|
||||
--bs-danger-bg-subtle: #f8d7da;
|
||||
--bs-light-bg-subtle: #fcfcfd;
|
||||
--bs-dark-bg-subtle: #ced4da;
|
||||
--bs-primary-border-subtle: #9ec5fe;
|
||||
--bs-secondary-border-subtle: #c4c8cb;
|
||||
--bs-success-border-subtle: #a3cfbb;
|
||||
--bs-info-border-subtle: #9eeaf9;
|
||||
--bs-warning-border-subtle: #ffe69c;
|
||||
--bs-danger-border-subtle: #f1aeb5;
|
||||
--bs-light-border-subtle: #e9ecef;
|
||||
--bs-dark-border-subtle: #adb5bd;
|
||||
--bs-white-rgb: 255, 255, 255;
|
||||
--bs-black-rgb: 0, 0, 0;
|
||||
--bs-font-sans-serif: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", "Liberation Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
|
||||
--bs-font-monospace: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||
--bs-gradient: linear-gradient(180deg, rgba(255, 255, 255, 0.15), rgba(255, 255, 255, 0));
|
||||
--bs-body-font-family: var(--bs-font-sans-serif);
|
||||
--bs-body-font-size: 1rem;
|
||||
--bs-body-font-weight: 400;
|
||||
--bs-body-line-height: 1.5;
|
||||
--bs-body-color: #212529;
|
||||
--bs-body-color-rgb: 33, 37, 41;
|
||||
--bs-body-bg: #fff;
|
||||
--bs-body-bg-rgb: 255, 255, 255;
|
||||
--bs-emphasis-color: #000;
|
||||
--bs-emphasis-color-rgb: 0, 0, 0;
|
||||
--bs-secondary-color: rgba(33, 37, 41, 0.75);
|
||||
--bs-secondary-color-rgb: 33, 37, 41;
|
||||
--bs-secondary-bg: #e9ecef;
|
||||
--bs-secondary-bg-rgb: 233, 236, 239;
|
||||
--bs-tertiary-color: rgba(33, 37, 41, 0.5);
|
||||
--bs-tertiary-color-rgb: 33, 37, 41;
|
||||
--bs-tertiary-bg: #f8f9fa;
|
||||
--bs-tertiary-bg-rgb: 248, 249, 250;
|
||||
--bs-heading-color: inherit;
|
||||
--bs-link-color: #0d6efd;
|
||||
--bs-link-color-rgb: 13, 110, 253;
|
||||
--bs-link-decoration: underline;
|
||||
--bs-link-hover-color: #0a58ca;
|
||||
--bs-link-hover-color-rgb: 10, 88, 202;
|
||||
--bs-code-color: #d63384;
|
||||
--bs-highlight-color: #212529;
|
||||
--bs-highlight-bg: #fff3cd;
|
||||
--bs-border-width: 1px;
|
||||
--bs-border-style: solid;
|
||||
--bs-border-color: #dee2e6;
|
||||
--bs-border-color-translucent: rgba(0, 0, 0, 0.175);
|
||||
--bs-border-radius: 0.375rem;
|
||||
--bs-border-radius-sm: 0.25rem;
|
||||
--bs-border-radius-lg: 0.5rem;
|
||||
--bs-border-radius-xl: 1rem;
|
||||
--bs-border-radius-xxl: 2rem;
|
||||
--bs-border-radius-2xl: var(--bs-border-radius-xxl);
|
||||
--bs-border-radius-pill: 50rem;
|
||||
--bs-box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
|
||||
--bs-box-shadow-sm: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
|
||||
--bs-box-shadow-lg: 0 1rem 3rem rgba(0, 0, 0, 0.175);
|
||||
--bs-box-shadow-inset: inset 0 1px 2px rgba(0, 0, 0, 0.075);
|
||||
--bs-focus-ring-width: 0.25rem;
|
||||
--bs-focus-ring-opacity: 0.25;
|
||||
--bs-focus-ring-color: rgba(13, 110, 253, 0.25);
|
||||
--bs-form-valid-color: #198754;
|
||||
--bs-form-valid-border-color: #198754;
|
||||
--bs-form-invalid-color: #dc3545;
|
||||
--bs-form-invalid-border-color: #dc3545;
|
||||
}
|
||||
|
||||
[data-bs-theme=dark] {
|
||||
color-scheme: dark;
|
||||
--bs-body-color: #dee2e6;
|
||||
--bs-body-color-rgb: 222, 226, 230;
|
||||
--bs-body-bg: #212529;
|
||||
--bs-body-bg-rgb: 33, 37, 41;
|
||||
--bs-emphasis-color: #fff;
|
||||
--bs-emphasis-color-rgb: 255, 255, 255;
|
||||
--bs-secondary-color: rgba(222, 226, 230, 0.75);
|
||||
--bs-secondary-color-rgb: 222, 226, 230;
|
||||
--bs-secondary-bg: #343a40;
|
||||
--bs-secondary-bg-rgb: 52, 58, 64;
|
||||
--bs-tertiary-color: rgba(222, 226, 230, 0.5);
|
||||
--bs-tertiary-color-rgb: 222, 226, 230;
|
||||
--bs-tertiary-bg: #2b3035;
|
||||
--bs-tertiary-bg-rgb: 43, 48, 53;
|
||||
--bs-primary-text-emphasis: #6ea8fe;
|
||||
--bs-secondary-text-emphasis: #a7acb1;
|
||||
--bs-success-text-emphasis: #75b798;
|
||||
--bs-info-text-emphasis: #6edff6;
|
||||
--bs-warning-text-emphasis: #ffda6a;
|
||||
--bs-danger-text-emphasis: #ea868f;
|
||||
--bs-light-text-emphasis: #f8f9fa;
|
||||
--bs-dark-text-emphasis: #dee2e6;
|
||||
--bs-primary-bg-subtle: #031633;
|
||||
--bs-secondary-bg-subtle: #161719;
|
||||
--bs-success-bg-subtle: #051b11;
|
||||
--bs-info-bg-subtle: #032830;
|
||||
--bs-warning-bg-subtle: #332701;
|
||||
--bs-danger-bg-subtle: #2c0b0e;
|
||||
--bs-light-bg-subtle: #343a40;
|
||||
--bs-dark-bg-subtle: #1a1d20;
|
||||
--bs-primary-border-subtle: #084298;
|
||||
--bs-secondary-border-subtle: #41464b;
|
||||
--bs-success-border-subtle: #0f5132;
|
||||
--bs-info-border-subtle: #087990;
|
||||
--bs-warning-border-subtle: #997404;
|
||||
--bs-danger-border-subtle: #842029;
|
||||
--bs-light-border-subtle: #495057;
|
||||
--bs-dark-border-subtle: #343a40;
|
||||
--bs-heading-color: inherit;
|
||||
--bs-link-color: #6ea8fe;
|
||||
--bs-link-hover-color: #8bb9fe;
|
||||
--bs-link-color-rgb: 110, 168, 254;
|
||||
--bs-link-hover-color-rgb: 139, 185, 254;
|
||||
--bs-code-color: #e685b5;
|
||||
--bs-highlight-color: #dee2e6;
|
||||
--bs-highlight-bg: #664d03;
|
||||
--bs-border-color: #495057;
|
||||
--bs-border-color-translucent: rgba(255, 255, 255, 0.15);
|
||||
--bs-form-valid-color: #75b798;
|
||||
--bs-form-valid-border-color: #75b798;
|
||||
--bs-form-invalid-color: #ea868f;
|
||||
--bs-form-invalid-border-color: #ea868f;
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
:root {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: var(--bs-body-font-family);
|
||||
font-size: var(--bs-body-font-size);
|
||||
font-weight: var(--bs-body-font-weight);
|
||||
line-height: var(--bs-body-line-height);
|
||||
color: var(--bs-body-color);
|
||||
text-align: var(--bs-body-text-align);
|
||||
background-color: var(--bs-body-bg);
|
||||
-webkit-text-size-adjust: 100%;
|
||||
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
|
||||
}
|
||||
|
||||
hr {
|
||||
margin: 1rem 0;
|
||||
color: inherit;
|
||||
border: 0;
|
||||
border-top: var(--bs-border-width) solid;
|
||||
opacity: 0.25;
|
||||
}
|
||||
|
||||
h6, h5, h4, h3, h2, h1 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.2;
|
||||
color: var(--bs-heading-color);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: calc(1.375rem + 1.5vw);
|
||||
}
|
||||
@media (min-width: 1200px) {
|
||||
h1 {
|
||||
font-size: 2.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: calc(1.325rem + 0.9vw);
|
||||
}
|
||||
@media (min-width: 1200px) {
|
||||
h2 {
|
||||
font-size: 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: calc(1.3rem + 0.6vw);
|
||||
}
|
||||
@media (min-width: 1200px) {
|
||||
h3 {
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
h4 {
|
||||
font-size: calc(1.275rem + 0.3vw);
|
||||
}
|
||||
@media (min-width: 1200px) {
|
||||
h4 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
h5 {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
h6 {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
p {
|
||||
margin-top: 0;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
abbr[title] {
|
||||
-webkit-text-decoration: underline dotted;
|
||||
text-decoration: underline dotted;
|
||||
cursor: help;
|
||||
-webkit-text-decoration-skip-ink: none;
|
||||
text-decoration-skip-ink: none;
|
||||
}
|
||||
|
||||
address {
|
||||
margin-bottom: 1rem;
|
||||
font-style: normal;
|
||||
line-height: inherit;
|
||||
}
|
||||
|
||||
ol,
|
||||
ul {
|
||||
padding-left: 2rem;
|
||||
}
|
||||
|
||||
ol,
|
||||
ul,
|
||||
dl {
|
||||
margin-top: 0;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
ol ol,
|
||||
ul ul,
|
||||
ol ul,
|
||||
ul ol {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
dt {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
dd {
|
||||
margin-bottom: 0.5rem;
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
blockquote {
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
|
||||
b,
|
||||
strong {
|
||||
font-weight: bolder;
|
||||
}
|
||||
|
||||
small {
|
||||
font-size: 0.875em;
|
||||
}
|
||||
|
||||
mark {
|
||||
padding: 0.1875em;
|
||||
color: var(--bs-highlight-color);
|
||||
background-color: var(--bs-highlight-bg);
|
||||
}
|
||||
|
||||
sub,
|
||||
sup {
|
||||
position: relative;
|
||||
font-size: 0.75em;
|
||||
line-height: 0;
|
||||
vertical-align: baseline;
|
||||
}
|
||||
|
||||
sub {
|
||||
bottom: -0.25em;
|
||||
}
|
||||
|
||||
sup {
|
||||
top: -0.5em;
|
||||
}
|
||||
|
||||
a {
|
||||
color: rgba(var(--bs-link-color-rgb), var(--bs-link-opacity, 1));
|
||||
text-decoration: underline;
|
||||
}
|
||||
a:hover {
|
||||
--bs-link-color-rgb: var(--bs-link-hover-color-rgb);
|
||||
}
|
||||
|
||||
a:not([href]):not([class]), a:not([href]):not([class]):hover {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
pre,
|
||||
code,
|
||||
kbd,
|
||||
samp {
|
||||
font-family: var(--bs-font-monospace);
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
pre {
|
||||
display: block;
|
||||
margin-top: 0;
|
||||
margin-bottom: 1rem;
|
||||
overflow: auto;
|
||||
font-size: 0.875em;
|
||||
}
|
||||
pre code {
|
||||
font-size: inherit;
|
||||
color: inherit;
|
||||
word-break: normal;
|
||||
}
|
||||
|
||||
code {
|
||||
font-size: 0.875em;
|
||||
color: var(--bs-code-color);
|
||||
word-wrap: break-word;
|
||||
}
|
||||
a > code {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
kbd {
|
||||
padding: 0.1875rem 0.375rem;
|
||||
font-size: 0.875em;
|
||||
color: var(--bs-body-bg);
|
||||
background-color: var(--bs-body-color);
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
kbd kbd {
|
||||
padding: 0;
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
figure {
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
|
||||
img,
|
||||
svg {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
table {
|
||||
caption-side: bottom;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
caption {
|
||||
padding-top: 0.5rem;
|
||||
padding-bottom: 0.5rem;
|
||||
color: var(--bs-secondary-color);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
th {
|
||||
text-align: inherit;
|
||||
text-align: -webkit-match-parent;
|
||||
}
|
||||
|
||||
thead,
|
||||
tbody,
|
||||
tfoot,
|
||||
tr,
|
||||
td,
|
||||
th {
|
||||
border-color: inherit;
|
||||
border-style: solid;
|
||||
border-width: 0;
|
||||
}
|
||||
|
||||
label {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
button {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
button:focus:not(:focus-visible) {
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
input,
|
||||
button,
|
||||
select,
|
||||
optgroup,
|
||||
textarea {
|
||||
margin: 0;
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
line-height: inherit;
|
||||
}
|
||||
|
||||
button,
|
||||
select {
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
[role=button] {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
select {
|
||||
word-wrap: normal;
|
||||
}
|
||||
select:disabled {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
[list]:not([type=date]):not([type=datetime-local]):not([type=month]):not([type=week]):not([type=time])::-webkit-calendar-picker-indicator {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
button,
|
||||
[type=button],
|
||||
[type=reset],
|
||||
[type=submit] {
|
||||
-webkit-appearance: button;
|
||||
}
|
||||
button:not(:disabled),
|
||||
[type=button]:not(:disabled),
|
||||
[type=reset]:not(:disabled),
|
||||
[type=submit]:not(:disabled) {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
::-moz-focus-inner {
|
||||
padding: 0;
|
||||
border-style: none;
|
||||
}
|
||||
|
||||
textarea {
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
fieldset {
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
legend {
|
||||
float: left;
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: calc(1.275rem + 0.3vw);
|
||||
line-height: inherit;
|
||||
}
|
||||
@media (min-width: 1200px) {
|
||||
legend {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
}
|
||||
legend + * {
|
||||
clear: left;
|
||||
}
|
||||
|
||||
::-webkit-datetime-edit-fields-wrapper,
|
||||
::-webkit-datetime-edit-text,
|
||||
::-webkit-datetime-edit-minute,
|
||||
::-webkit-datetime-edit-hour-field,
|
||||
::-webkit-datetime-edit-day-field,
|
||||
::-webkit-datetime-edit-month-field,
|
||||
::-webkit-datetime-edit-year-field {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
::-webkit-inner-spin-button {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
[type=search] {
|
||||
-webkit-appearance: textfield;
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
/* rtl:raw:
|
||||
[type="tel"],
|
||||
[type="url"],
|
||||
[type="email"],
|
||||
[type="number"] {
|
||||
direction: ltr;
|
||||
}
|
||||
*/
|
||||
::-webkit-search-decoration {
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
|
||||
::-webkit-color-swatch-wrapper {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
::-webkit-file-upload-button {
|
||||
font: inherit;
|
||||
-webkit-appearance: button;
|
||||
}
|
||||
|
||||
::file-selector-button {
|
||||
font: inherit;
|
||||
-webkit-appearance: button;
|
||||
}
|
||||
|
||||
output {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
iframe {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
summary {
|
||||
display: list-item;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
progress {
|
||||
vertical-align: baseline;
|
||||
}
|
||||
|
||||
[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/*# sourceMappingURL=bootstrap-reboot.css.map */
|
||||
+1
File diff suppressed because one or more lines are too long
+6
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
+594
@@ -0,0 +1,594 @@
|
||||
/*!
|
||||
* Bootstrap Reboot v5.3.3 (https://getbootstrap.com/)
|
||||
* Copyright 2011-2024 The Bootstrap Authors
|
||||
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
|
||||
*/
|
||||
:root,
|
||||
[data-bs-theme=light] {
|
||||
--bs-blue: #0d6efd;
|
||||
--bs-indigo: #6610f2;
|
||||
--bs-purple: #6f42c1;
|
||||
--bs-pink: #d63384;
|
||||
--bs-red: #dc3545;
|
||||
--bs-orange: #fd7e14;
|
||||
--bs-yellow: #ffc107;
|
||||
--bs-green: #198754;
|
||||
--bs-teal: #20c997;
|
||||
--bs-cyan: #0dcaf0;
|
||||
--bs-black: #000;
|
||||
--bs-white: #fff;
|
||||
--bs-gray: #6c757d;
|
||||
--bs-gray-dark: #343a40;
|
||||
--bs-gray-100: #f8f9fa;
|
||||
--bs-gray-200: #e9ecef;
|
||||
--bs-gray-300: #dee2e6;
|
||||
--bs-gray-400: #ced4da;
|
||||
--bs-gray-500: #adb5bd;
|
||||
--bs-gray-600: #6c757d;
|
||||
--bs-gray-700: #495057;
|
||||
--bs-gray-800: #343a40;
|
||||
--bs-gray-900: #212529;
|
||||
--bs-primary: #0d6efd;
|
||||
--bs-secondary: #6c757d;
|
||||
--bs-success: #198754;
|
||||
--bs-info: #0dcaf0;
|
||||
--bs-warning: #ffc107;
|
||||
--bs-danger: #dc3545;
|
||||
--bs-light: #f8f9fa;
|
||||
--bs-dark: #212529;
|
||||
--bs-primary-rgb: 13, 110, 253;
|
||||
--bs-secondary-rgb: 108, 117, 125;
|
||||
--bs-success-rgb: 25, 135, 84;
|
||||
--bs-info-rgb: 13, 202, 240;
|
||||
--bs-warning-rgb: 255, 193, 7;
|
||||
--bs-danger-rgb: 220, 53, 69;
|
||||
--bs-light-rgb: 248, 249, 250;
|
||||
--bs-dark-rgb: 33, 37, 41;
|
||||
--bs-primary-text-emphasis: #052c65;
|
||||
--bs-secondary-text-emphasis: #2b2f32;
|
||||
--bs-success-text-emphasis: #0a3622;
|
||||
--bs-info-text-emphasis: #055160;
|
||||
--bs-warning-text-emphasis: #664d03;
|
||||
--bs-danger-text-emphasis: #58151c;
|
||||
--bs-light-text-emphasis: #495057;
|
||||
--bs-dark-text-emphasis: #495057;
|
||||
--bs-primary-bg-subtle: #cfe2ff;
|
||||
--bs-secondary-bg-subtle: #e2e3e5;
|
||||
--bs-success-bg-subtle: #d1e7dd;
|
||||
--bs-info-bg-subtle: #cff4fc;
|
||||
--bs-warning-bg-subtle: #fff3cd;
|
||||
--bs-danger-bg-subtle: #f8d7da;
|
||||
--bs-light-bg-subtle: #fcfcfd;
|
||||
--bs-dark-bg-subtle: #ced4da;
|
||||
--bs-primary-border-subtle: #9ec5fe;
|
||||
--bs-secondary-border-subtle: #c4c8cb;
|
||||
--bs-success-border-subtle: #a3cfbb;
|
||||
--bs-info-border-subtle: #9eeaf9;
|
||||
--bs-warning-border-subtle: #ffe69c;
|
||||
--bs-danger-border-subtle: #f1aeb5;
|
||||
--bs-light-border-subtle: #e9ecef;
|
||||
--bs-dark-border-subtle: #adb5bd;
|
||||
--bs-white-rgb: 255, 255, 255;
|
||||
--bs-black-rgb: 0, 0, 0;
|
||||
--bs-font-sans-serif: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", "Liberation Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
|
||||
--bs-font-monospace: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||
--bs-gradient: linear-gradient(180deg, rgba(255, 255, 255, 0.15), rgba(255, 255, 255, 0));
|
||||
--bs-body-font-family: var(--bs-font-sans-serif);
|
||||
--bs-body-font-size: 1rem;
|
||||
--bs-body-font-weight: 400;
|
||||
--bs-body-line-height: 1.5;
|
||||
--bs-body-color: #212529;
|
||||
--bs-body-color-rgb: 33, 37, 41;
|
||||
--bs-body-bg: #fff;
|
||||
--bs-body-bg-rgb: 255, 255, 255;
|
||||
--bs-emphasis-color: #000;
|
||||
--bs-emphasis-color-rgb: 0, 0, 0;
|
||||
--bs-secondary-color: rgba(33, 37, 41, 0.75);
|
||||
--bs-secondary-color-rgb: 33, 37, 41;
|
||||
--bs-secondary-bg: #e9ecef;
|
||||
--bs-secondary-bg-rgb: 233, 236, 239;
|
||||
--bs-tertiary-color: rgba(33, 37, 41, 0.5);
|
||||
--bs-tertiary-color-rgb: 33, 37, 41;
|
||||
--bs-tertiary-bg: #f8f9fa;
|
||||
--bs-tertiary-bg-rgb: 248, 249, 250;
|
||||
--bs-heading-color: inherit;
|
||||
--bs-link-color: #0d6efd;
|
||||
--bs-link-color-rgb: 13, 110, 253;
|
||||
--bs-link-decoration: underline;
|
||||
--bs-link-hover-color: #0a58ca;
|
||||
--bs-link-hover-color-rgb: 10, 88, 202;
|
||||
--bs-code-color: #d63384;
|
||||
--bs-highlight-color: #212529;
|
||||
--bs-highlight-bg: #fff3cd;
|
||||
--bs-border-width: 1px;
|
||||
--bs-border-style: solid;
|
||||
--bs-border-color: #dee2e6;
|
||||
--bs-border-color-translucent: rgba(0, 0, 0, 0.175);
|
||||
--bs-border-radius: 0.375rem;
|
||||
--bs-border-radius-sm: 0.25rem;
|
||||
--bs-border-radius-lg: 0.5rem;
|
||||
--bs-border-radius-xl: 1rem;
|
||||
--bs-border-radius-xxl: 2rem;
|
||||
--bs-border-radius-2xl: var(--bs-border-radius-xxl);
|
||||
--bs-border-radius-pill: 50rem;
|
||||
--bs-box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
|
||||
--bs-box-shadow-sm: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
|
||||
--bs-box-shadow-lg: 0 1rem 3rem rgba(0, 0, 0, 0.175);
|
||||
--bs-box-shadow-inset: inset 0 1px 2px rgba(0, 0, 0, 0.075);
|
||||
--bs-focus-ring-width: 0.25rem;
|
||||
--bs-focus-ring-opacity: 0.25;
|
||||
--bs-focus-ring-color: rgba(13, 110, 253, 0.25);
|
||||
--bs-form-valid-color: #198754;
|
||||
--bs-form-valid-border-color: #198754;
|
||||
--bs-form-invalid-color: #dc3545;
|
||||
--bs-form-invalid-border-color: #dc3545;
|
||||
}
|
||||
|
||||
[data-bs-theme=dark] {
|
||||
color-scheme: dark;
|
||||
--bs-body-color: #dee2e6;
|
||||
--bs-body-color-rgb: 222, 226, 230;
|
||||
--bs-body-bg: #212529;
|
||||
--bs-body-bg-rgb: 33, 37, 41;
|
||||
--bs-emphasis-color: #fff;
|
||||
--bs-emphasis-color-rgb: 255, 255, 255;
|
||||
--bs-secondary-color: rgba(222, 226, 230, 0.75);
|
||||
--bs-secondary-color-rgb: 222, 226, 230;
|
||||
--bs-secondary-bg: #343a40;
|
||||
--bs-secondary-bg-rgb: 52, 58, 64;
|
||||
--bs-tertiary-color: rgba(222, 226, 230, 0.5);
|
||||
--bs-tertiary-color-rgb: 222, 226, 230;
|
||||
--bs-tertiary-bg: #2b3035;
|
||||
--bs-tertiary-bg-rgb: 43, 48, 53;
|
||||
--bs-primary-text-emphasis: #6ea8fe;
|
||||
--bs-secondary-text-emphasis: #a7acb1;
|
||||
--bs-success-text-emphasis: #75b798;
|
||||
--bs-info-text-emphasis: #6edff6;
|
||||
--bs-warning-text-emphasis: #ffda6a;
|
||||
--bs-danger-text-emphasis: #ea868f;
|
||||
--bs-light-text-emphasis: #f8f9fa;
|
||||
--bs-dark-text-emphasis: #dee2e6;
|
||||
--bs-primary-bg-subtle: #031633;
|
||||
--bs-secondary-bg-subtle: #161719;
|
||||
--bs-success-bg-subtle: #051b11;
|
||||
--bs-info-bg-subtle: #032830;
|
||||
--bs-warning-bg-subtle: #332701;
|
||||
--bs-danger-bg-subtle: #2c0b0e;
|
||||
--bs-light-bg-subtle: #343a40;
|
||||
--bs-dark-bg-subtle: #1a1d20;
|
||||
--bs-primary-border-subtle: #084298;
|
||||
--bs-secondary-border-subtle: #41464b;
|
||||
--bs-success-border-subtle: #0f5132;
|
||||
--bs-info-border-subtle: #087990;
|
||||
--bs-warning-border-subtle: #997404;
|
||||
--bs-danger-border-subtle: #842029;
|
||||
--bs-light-border-subtle: #495057;
|
||||
--bs-dark-border-subtle: #343a40;
|
||||
--bs-heading-color: inherit;
|
||||
--bs-link-color: #6ea8fe;
|
||||
--bs-link-hover-color: #8bb9fe;
|
||||
--bs-link-color-rgb: 110, 168, 254;
|
||||
--bs-link-hover-color-rgb: 139, 185, 254;
|
||||
--bs-code-color: #e685b5;
|
||||
--bs-highlight-color: #dee2e6;
|
||||
--bs-highlight-bg: #664d03;
|
||||
--bs-border-color: #495057;
|
||||
--bs-border-color-translucent: rgba(255, 255, 255, 0.15);
|
||||
--bs-form-valid-color: #75b798;
|
||||
--bs-form-valid-border-color: #75b798;
|
||||
--bs-form-invalid-color: #ea868f;
|
||||
--bs-form-invalid-border-color: #ea868f;
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
:root {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: var(--bs-body-font-family);
|
||||
font-size: var(--bs-body-font-size);
|
||||
font-weight: var(--bs-body-font-weight);
|
||||
line-height: var(--bs-body-line-height);
|
||||
color: var(--bs-body-color);
|
||||
text-align: var(--bs-body-text-align);
|
||||
background-color: var(--bs-body-bg);
|
||||
-webkit-text-size-adjust: 100%;
|
||||
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
|
||||
}
|
||||
|
||||
hr {
|
||||
margin: 1rem 0;
|
||||
color: inherit;
|
||||
border: 0;
|
||||
border-top: var(--bs-border-width) solid;
|
||||
opacity: 0.25;
|
||||
}
|
||||
|
||||
h6, h5, h4, h3, h2, h1 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.2;
|
||||
color: var(--bs-heading-color);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: calc(1.375rem + 1.5vw);
|
||||
}
|
||||
@media (min-width: 1200px) {
|
||||
h1 {
|
||||
font-size: 2.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: calc(1.325rem + 0.9vw);
|
||||
}
|
||||
@media (min-width: 1200px) {
|
||||
h2 {
|
||||
font-size: 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: calc(1.3rem + 0.6vw);
|
||||
}
|
||||
@media (min-width: 1200px) {
|
||||
h3 {
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
h4 {
|
||||
font-size: calc(1.275rem + 0.3vw);
|
||||
}
|
||||
@media (min-width: 1200px) {
|
||||
h4 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
h5 {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
h6 {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
p {
|
||||
margin-top: 0;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
abbr[title] {
|
||||
-webkit-text-decoration: underline dotted;
|
||||
text-decoration: underline dotted;
|
||||
cursor: help;
|
||||
-webkit-text-decoration-skip-ink: none;
|
||||
text-decoration-skip-ink: none;
|
||||
}
|
||||
|
||||
address {
|
||||
margin-bottom: 1rem;
|
||||
font-style: normal;
|
||||
line-height: inherit;
|
||||
}
|
||||
|
||||
ol,
|
||||
ul {
|
||||
padding-right: 2rem;
|
||||
}
|
||||
|
||||
ol,
|
||||
ul,
|
||||
dl {
|
||||
margin-top: 0;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
ol ol,
|
||||
ul ul,
|
||||
ol ul,
|
||||
ul ol {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
dt {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
dd {
|
||||
margin-bottom: 0.5rem;
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
blockquote {
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
|
||||
b,
|
||||
strong {
|
||||
font-weight: bolder;
|
||||
}
|
||||
|
||||
small {
|
||||
font-size: 0.875em;
|
||||
}
|
||||
|
||||
mark {
|
||||
padding: 0.1875em;
|
||||
color: var(--bs-highlight-color);
|
||||
background-color: var(--bs-highlight-bg);
|
||||
}
|
||||
|
||||
sub,
|
||||
sup {
|
||||
position: relative;
|
||||
font-size: 0.75em;
|
||||
line-height: 0;
|
||||
vertical-align: baseline;
|
||||
}
|
||||
|
||||
sub {
|
||||
bottom: -0.25em;
|
||||
}
|
||||
|
||||
sup {
|
||||
top: -0.5em;
|
||||
}
|
||||
|
||||
a {
|
||||
color: rgba(var(--bs-link-color-rgb), var(--bs-link-opacity, 1));
|
||||
text-decoration: underline;
|
||||
}
|
||||
a:hover {
|
||||
--bs-link-color-rgb: var(--bs-link-hover-color-rgb);
|
||||
}
|
||||
|
||||
a:not([href]):not([class]), a:not([href]):not([class]):hover {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
pre,
|
||||
code,
|
||||
kbd,
|
||||
samp {
|
||||
font-family: var(--bs-font-monospace);
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
pre {
|
||||
display: block;
|
||||
margin-top: 0;
|
||||
margin-bottom: 1rem;
|
||||
overflow: auto;
|
||||
font-size: 0.875em;
|
||||
}
|
||||
pre code {
|
||||
font-size: inherit;
|
||||
color: inherit;
|
||||
word-break: normal;
|
||||
}
|
||||
|
||||
code {
|
||||
font-size: 0.875em;
|
||||
color: var(--bs-code-color);
|
||||
word-wrap: break-word;
|
||||
}
|
||||
a > code {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
kbd {
|
||||
padding: 0.1875rem 0.375rem;
|
||||
font-size: 0.875em;
|
||||
color: var(--bs-body-bg);
|
||||
background-color: var(--bs-body-color);
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
kbd kbd {
|
||||
padding: 0;
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
figure {
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
|
||||
img,
|
||||
svg {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
table {
|
||||
caption-side: bottom;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
caption {
|
||||
padding-top: 0.5rem;
|
||||
padding-bottom: 0.5rem;
|
||||
color: var(--bs-secondary-color);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
th {
|
||||
text-align: inherit;
|
||||
text-align: -webkit-match-parent;
|
||||
}
|
||||
|
||||
thead,
|
||||
tbody,
|
||||
tfoot,
|
||||
tr,
|
||||
td,
|
||||
th {
|
||||
border-color: inherit;
|
||||
border-style: solid;
|
||||
border-width: 0;
|
||||
}
|
||||
|
||||
label {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
button {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
button:focus:not(:focus-visible) {
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
input,
|
||||
button,
|
||||
select,
|
||||
optgroup,
|
||||
textarea {
|
||||
margin: 0;
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
line-height: inherit;
|
||||
}
|
||||
|
||||
button,
|
||||
select {
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
[role=button] {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
select {
|
||||
word-wrap: normal;
|
||||
}
|
||||
select:disabled {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
[list]:not([type=date]):not([type=datetime-local]):not([type=month]):not([type=week]):not([type=time])::-webkit-calendar-picker-indicator {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
button,
|
||||
[type=button],
|
||||
[type=reset],
|
||||
[type=submit] {
|
||||
-webkit-appearance: button;
|
||||
}
|
||||
button:not(:disabled),
|
||||
[type=button]:not(:disabled),
|
||||
[type=reset]:not(:disabled),
|
||||
[type=submit]:not(:disabled) {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
::-moz-focus-inner {
|
||||
padding: 0;
|
||||
border-style: none;
|
||||
}
|
||||
|
||||
textarea {
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
fieldset {
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
legend {
|
||||
float: right;
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: calc(1.275rem + 0.3vw);
|
||||
line-height: inherit;
|
||||
}
|
||||
@media (min-width: 1200px) {
|
||||
legend {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
}
|
||||
legend + * {
|
||||
clear: right;
|
||||
}
|
||||
|
||||
::-webkit-datetime-edit-fields-wrapper,
|
||||
::-webkit-datetime-edit-text,
|
||||
::-webkit-datetime-edit-minute,
|
||||
::-webkit-datetime-edit-hour-field,
|
||||
::-webkit-datetime-edit-day-field,
|
||||
::-webkit-datetime-edit-month-field,
|
||||
::-webkit-datetime-edit-year-field {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
::-webkit-inner-spin-button {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
[type=search] {
|
||||
-webkit-appearance: textfield;
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
[type="tel"],
|
||||
[type="url"],
|
||||
[type="email"],
|
||||
[type="number"] {
|
||||
direction: ltr;
|
||||
}
|
||||
::-webkit-search-decoration {
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
|
||||
::-webkit-color-swatch-wrapper {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
::-webkit-file-upload-button {
|
||||
font: inherit;
|
||||
-webkit-appearance: button;
|
||||
}
|
||||
|
||||
::file-selector-button {
|
||||
font: inherit;
|
||||
-webkit-appearance: button;
|
||||
}
|
||||
|
||||
output {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
iframe {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
summary {
|
||||
display: list-item;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
progress {
|
||||
vertical-align: baseline;
|
||||
}
|
||||
|
||||
[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
/*# sourceMappingURL=bootstrap-reboot.rtl.css.map */
|
||||
+1
File diff suppressed because one or more lines are too long
+6
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
+5402
File diff suppressed because it is too large
Load Diff
+1
File diff suppressed because one or more lines are too long
+6
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
+5393
File diff suppressed because it is too large
Load Diff
+1
File diff suppressed because one or more lines are too long
+6
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,27 @@
|
||||
[
|
||||
{
|
||||
"date": "2022-01-06",
|
||||
"temperatureC": 1,
|
||||
"summary": "Freezing"
|
||||
},
|
||||
{
|
||||
"date": "2022-01-07",
|
||||
"temperatureC": 14,
|
||||
"summary": "Bracing"
|
||||
},
|
||||
{
|
||||
"date": "2022-01-08",
|
||||
"temperatureC": -13,
|
||||
"summary": "Freezing"
|
||||
},
|
||||
{
|
||||
"date": "2022-01-09",
|
||||
"temperatureC": -16,
|
||||
"summary": "Balmy"
|
||||
},
|
||||
{
|
||||
"date": "2022-01-10",
|
||||
"temperatureC": -2,
|
||||
"summary": "Chilly"
|
||||
}
|
||||
]
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user