20 Commits
Author SHA1 Message Date
darkstack c9a3c252e1 Docker compose / Fix build ?
Build App / Build (push) Failing after 2m0s
2026-08-06 11:00:55 +02:00
darkstack a9860f4c94 Added this stupid vibecoded app as a test
Build App / Build (push) Waiting to run
2026-08-06 09:58:06 +02:00
darkstack d9e05fb487 Update to dotnet 9.0, add user roles, MatchStats and OpenApi/Scalar in dev 2026-08-06 09:56:07 +02:00
darkstack e10663c8c0 Update Readme
Build App / Build (push) Successful in 1m31s
2025-03-08 01:17:53 +01:00
darkstack cd03b39c20 Fix Discord Intents
Build App / Build (push) Successful in 2m14s
2025-03-07 15:14:38 +01:00
darkstack 9d8a7b3100 Fix DiscordBot
Build App / Build (push) Successful in 1m41s
2025-03-07 14:55:00 +01:00
darkstack e99479d8fb Fix Tags
Build App / Build (push) Successful in 2m2s
2025-03-07 13:15:11 +01:00
darkstack a9150ff58c URL Api Smash + Slug in HTML
Build App / Build (push) Successful in 1m36s
2025-03-07 10:56:24 +01:00
darkstack fba822a0af Build
Build App / Build (push) Successful in 1m40s
2025-02-09 22:39:17 +01:00
darkstack 73407e5867 Dot net 8
Build App / Build (push) Failing after 43s
2025-02-09 22:30:00 +01:00
darkstack 91664406c4 Test Update Avalonia
Build App / Build (push) Successful in 1m42s
2025-01-28 22:22:03 +01:00
darkstack bc95ef157d Wait 2025-01-28 22:01:30 +01:00
darkstack 454c12a5a9 Modification get smash
Build App / Build (push) Successful in 1m53s
2024-03-22 23:58:43 +01:00
darkstack 88fb4935b5 Config for the desktop app
Build App / Build (push) Successful in 1m28s
2024-03-16 14:14:17 +01:00
darkstack ede8ff088c Postgres Dump
Remove old Db dump
2024-03-16 14:14:13 +01:00
darkstack f466bb9174 Fix Warnings
Test Rlz
2024-03-16 14:14:08 +01:00
darkstack b7f65a5a98 Windows and linux artifacts 2024-03-16 14:14:08 +01:00
darkstack 212527bfc9 Gitea Action now build windows and linux image for the client 2024-03-16 14:14:08 +01:00
darkstack 4d1df14fe5 Avalonia Start 2024-03-16 14:14:08 +01:00
darkstack 99257c3422 Fix Restsharp / Move to Postgresql 2024-03-16 14:14:02 +01:00
141 changed files with 13935 additions and 4755 deletions
+33
View File
@@ -0,0 +1,33 @@
# Copy to .env next to docker-compose.yml and edit. Compose reads it automatically;
# git ignores .env. Every value here is optional — the defaults in docker-compose.yml
# are what you get without it.
# --- Ports on your machine -----------------------------------------------------------
# Container-side ports are fixed (API 5000, nginx 80); only these move.
#LADOSE_WEB_PORT=8080
#LADOSE_API_PORT=5000
# --- Database ------------------------------------------------------------------------
# Overrides ConnectionStrings:DbContext from LaDOSE.Api/appsettings.json. The stack has
# no Postgres of its own, so this has to point at a reachable server.
#
# Postgres on the machine running the containers:
#LADOSE_DB_CONNECTION=Host=host.docker.internal;Username=tom;Password=tom;Database=ladoseapi
#
# Postgres elsewhere on the LAN — check the name resolves *inside* the container
# (`docker compose run --rm api getent hosts kafka.local`) and use the IP if it does not:
#LADOSE_DB_CONNECTION=Host=kafka.local;Username=tom;Password=tom;Database=ladoseapi
#
# Sql/dump_20240316.sql then Sql/2026-08-05_roles.sql populate an empty database.
# --- Secrets -------------------------------------------------------------------------
# appsettings.json carries placeholders for these. Any value works for JWT locally, but
# it must be at least 32 characters: Startup.cs feeds it to HMAC-SHA256 as raw ASCII.
#LADOSE_JWT_SECRET=dev-only-secret-not-for-any-deployed-environment
#LADOSE_SMASH_API_KEY=
#LADOSE_CHALLONGE_API_KEY=
# --- Frontend ------------------------------------------------------------------------
# Only needed if the API is not on http://localhost:${LADOSE_API_PORT}. Resolved by the
# browser, so container names like http://api:5000 will not work.
#LADOSE_API_BASE_URL=http://localhost:5000
+69
View File
@@ -0,0 +1,69 @@
name: Build App
run-name: ${{ gitea.actor }} is building
on: [push]
jobs:
Build:
runs-on: ubuntu-latest
steps:
- name: Update
run: |
sudo apt-get update -y -qq
sudo apt-get install zip
sudo sh -c "echo '192.168.1.253 descartes.local' >> /etc/hosts"
sudo sh -c "echo '192.168.1.253 build.ladose.net' >> /etc/hosts"
- name: GetDNS
run: |
cat /etc/resolv.conf
- name: Setup .NET 6.x
uses: actions/setup-dotnet@v3
with:
# Semantic version range syntax or exact version of a dotnet version
dotnet-version: '8.x'
- run: echo "Build."
- name: Check out repository code
uses: actions/checkout@v3
- name: Restore Deps
run : |
cd LaDOSE.Src/
dotnet restore LaDOSE.linux.sln
- name: Build
run: |
cd LaDOSE.Src/
dotnet build --configuration Release --os linux LaDOSE.DesktopApp.Avalonia
dotnet build --configuration Release --os win LaDOSE.DesktopApp.Avalonia
- name: Zip file
run: |
zip -rj build-winx64.zip ./LaDOSE.Src/LaDOSE.DesktopApp.Avalonia/bin/Release/net8.0/win-x64/
zip -rj build-linux64.zip ./LaDOSE.Src/LaDOSE.DesktopApp.Avalonia/bin/Release/net8.0/linux-x64/
- name: Upload Artifact Windows
uses: actions/upload-artifact@v3
with:
path: build-linux64.zip
name: build-linux64.zip
retention-days: 30
overwrite: true
- name: Upload Artifact Linux
uses: actions/upload-artifact@v3
with:
path: build-winx64.zip
name: build-winx64.zip
retention-days: 30
overwrite: true
- name: Get current date
id: date
run: echo "date=$(echo $(date +'%Y-%m-%d'))" >> $GITHUB_OUTPUT
- name: Release
if: github.ref_name == 'master'
uses: akkuman/gitea-release-action@v1
env:
with:
tag_name: release-${{ steps.date.outputs.date }}
files: |-
build-winx64.zip
build-linux64.zip
+7
View File
@@ -328,3 +328,10 @@ ASALocalRun/
# MFractors (Xamarin productivity tool) working folder
.mfractor/
# Local docker-compose overrides: connection string, API keys, ports.
# .env.example is documentation and stays tracked.
.env
.env.*
!.env.example
docker-compose.override.yml
+22 -2
View File
@@ -1,2 +1,22 @@
*/*/bin*
*/*/obj*
# Context for LaDOSE.Src/Dockerfile.
#
# The previous patterns here were */*/bin* and */*/obj*, which matched nothing: this
# context is rooted at LaDOSE.Src, so build output sits one level down (LaDOSE.Api/bin),
# not two.
**/bin/
**/obj/
# The frontend is a separate image with its own context (LaDOSE.WebApp/Dockerfile).
# Its node_modules alone was adding ~118 MB to every API build.
LaDOSE.WebApp/node_modules/
LaDOSE.WebApp/build/
LaDOSE.WebApp/.svelte-kit/
# Local state and editor noise. Note Libraries/ is NOT excluded: LaDOSE.Business
# references ChallongeCSharpDriver.dll from there by HintPath.
.git
.vs/
.vscode/
.idea/
*.user
*.suo
+40 -11
View File
@@ -1,15 +1,44 @@
FROM microsoft/dotnet:sdk AS build-env
# Builds the LaDOSE.Api image. Context is LaDOSE.Src (see .dockerignore next to this file).
#
# Only LaDOSE.Api is published. LaDOSE.linux.sln also carries the Avalonia desktop app,
# the Discord bot and LinuxTest, and `dotnet publish <sln> -o out` flattens every project
# into that one directory — which is why the previous version of this file copied from
# /app/LaDOSE.Api/out/ and found nothing there.
ARG DOTNET_VERSION=9.0
FROM mcr.microsoft.com/dotnet/sdk:${DOTNET_VERSION} AS build
WORKDIR /src
# Debug is deliberate for local work, and docker-compose.yml passes it: the OpenAPI
# document and the Scalar UI are gated behind `#if DEBUG` in Startup.cs *and* behind
# Condition="'$(Configuration)' == 'Debug'" on their PackageReferences in
# LaDOSE.Api.csproj. A Release image therefore serves no /openapi/v1.json, which is
# exactly what LaDOSE.WebApp's `npm run api:sync` reads. Default stays Release.
ARG BUILD_CONFIGURATION=Release
# Project files first so this layer survives every .cs edit. Restore has to run under
# the same Configuration as the publish below, or the conditional PackageReferences
# above make the two disagree about which packages the assets file should contain.
COPY global.json ./
COPY LaDOSE.Api/LaDOSE.Api.csproj LaDOSE.Api/
COPY LaDOSE.DTO/LaDOSE.DTO.csproj LaDOSE.DTO/
COPY LaDOSE.Entity/LaDOSE.Entity.csproj LaDOSE.Entity/
COPY LaDOSE.Service/LaDOSE.Business.csproj LaDOSE.Service/
RUN dotnet restore LaDOSE.Api/LaDOSE.Api.csproj -p:Configuration=${BUILD_CONFIGURATION}
# Libraries/ChallongeCSharpDriver.dll is a HintPath reference from LaDOSE.Business,
# so the build needs the whole tree, not just the projects listed above.
COPY . .
RUN dotnet publish LaDOSE.Api/LaDOSE.Api.csproj -c ${BUILD_CONFIGURATION} --no-restore -o /app/out
FROM mcr.microsoft.com/dotnet/aspnet:${DOTNET_VERSION}
WORKDIR /app
COPY --from=build /app/out/ ./
# Copy everything else and build
COPY . ./
RUN dotnet publish LaDOSE.linux.sln -c Release -o out
# Build runtime image
FROM microsoft/dotnet:aspnetcore-runtime
WORKDIR /app
COPY --from=build-env /app/LaDOSE.Api/out/ .
# Fixed in the image on purpose. Program.cs binds Kestrel from appsettings.json's
# AllowedHosts/Port through a ConfigurationBuilder that reads *only* that file, so a
# Port env var would not move the listener — remap on the host side instead.
# Everything Startup.cs reads does honour env vars (ConnectionStrings__DbContext,
# ApiKey__SmashApiKey, ApiKey__ChallongeApiKey, JWTTokenSecret).
EXPOSE 5000
ENTRYPOINT ["dotnet", "LaDOSE.Api.dll"]
@@ -17,8 +17,17 @@ namespace LaDOSE.Api.Controllers
[Produces("application/json")]
public class GameController : GenericControllerDTO<IGameService, Game, GameDTO>
{
public GameController(IMapper mapper,IGameService service) : base(mapper,service)
private IExternalProviderService provider;
public GameController(IMapper mapper,IGameService service, IExternalProviderService service2) : base(mapper,service)
{
provider = service2;
}
[HttpGet("smash/{name}")]
public async Task<List<GameDTO>> GetIdFromSmash(string name)
{
var smashGame = await provider.GetSmashGame(name);
return _mapper.Map<List<GameDTO>>(smashGame);;
}
}
}
@@ -0,0 +1,38 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using AutoMapper;
using LaDOSE.Business.Interface;
using LaDOSE.DTO;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace LaDOSE.Api.Controllers
{
[Authorize]
[Produces("application/json")]
[Route("api/[controller]")]
public class StatisticsController : Controller
{
private readonly IStatisticsService _service;
private readonly IMapper _mapper;
public StatisticsController(IMapper mapper, IStatisticsService service)
{
_mapper = mapper;
_service = service;
}
/// <summary>
/// Match statistics for the given Event ids. Body is a bare JSON array of ids,
/// like TournamentController.GetResults.
/// A null or empty array returns a zeroed, empty MatchStatsDTO rather than an error.
/// Read Coverage before trusting the numbers: many brackets have no set rows at all.
/// </summary>
[HttpPost("Matches")]
public async Task<MatchStatsDTO> GetMatchStats([FromBody] List<int> ids)
{
var stats = await _service.GetMatchStats(ids);
return _mapper.Map<MatchStatsDTO>(stats);
}
}
}
@@ -19,7 +19,7 @@ namespace LaDOSE.Api.Controllers
private IMapper _mapper;
// GET
// GETawa
public TournamentController(IMapper mapper, IExternalProviderService service)
{
_mapper = mapper;
@@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
using System.IdentityModel.Tokens.Jwt;
using System.Linq;
@@ -9,6 +9,7 @@ using LaDOSE.Business.Interface;
using LaDOSE.DTO;
using LaDOSE.Entity;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Options;
@@ -33,12 +34,32 @@ namespace LaDOSE.Api.Controllers
_configuration = configuration;
}
/// <summary>Public view of a user: no password, no hash, no salt.</summary>
private static ApplicationUserDTO ToDto(ApplicationUser user)
{
return new ApplicationUserDTO
{
Id = user.Id,
Username = user.Username,
FirstName = user.FirstName,
LastName = user.LastName,
Roles = user.Names()
};
}
/// <summary>The id the JWT was issued for; null if the request is not authenticated.</summary>
private int? CurrentUserId()
{
return int.TryParse(User?.Identity?.Name, out var id) ? id : (int?)null;
}
[AllowAnonymous]
[HttpPost("auth")]
public IActionResult Authenticate([FromBody]ApplicationUser userDto)
[ProducesResponseType(typeof(ApplicationUserDTO), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public IActionResult Authenticate([FromBody]ApplicationUserDTO userDto)
{
var user = _userService.Authenticate(userDto.Username, userDto.Password);
var user = _userService.Authenticate(userDto?.Username, userDto?.Password);
if (user == null)
return BadRequest(new { message = "Username or password is incorrect" });
@@ -47,6 +68,9 @@ namespace LaDOSE.Api.Controllers
var key = Encoding.ASCII.GetBytes(this._configuration["JWTTokenSecret"]);
var tokenDescriptor = new SecurityTokenDescriptor
{
// Only the user id goes in the token. Roles are read from the database on
// every request instead, so granting or revoking Admin takes effect at
// once rather than whenever the current token happens to expire.
Subject = new ClaimsIdentity(new Claim[]
{
new Claim(ClaimTypes.Name, user.Id.ToString()),
@@ -60,29 +84,62 @@ namespace LaDOSE.Api.Controllers
var tokenString = tokenHandler.WriteToken(token);
// return basic user info (without password) and token to store client side
return Ok(new ApplicationUserDTO
{
Id = user.Id,
Username = user.Username,
FirstName = user.FirstName,
LastName = user.LastName,
Token = tokenString,
Expire = token.ValidTo
});
var dto = ToDto(user);
dto.Token = tokenString;
dto.Expire = token.ValidTo;
return Ok(dto);
}
//[AllowAnonymous]
[HttpPost("register")]
public IActionResult Register([FromBody]ApplicationUser userDto)
/// <summary>Every account, for the admin user-management screen.</summary>
[Authorize(Roles = Roles.Admin)]
[HttpGet]
[ProducesResponseType(typeof(List<ApplicationUserDTO>), StatusCodes.Status200OK)]
public IActionResult GetUsers()
{
// map dto to entity
var users = _userService.GetAll()
.OrderBy(user => user.Username)
.Select(ToDto)
.ToList();
return Ok(users);
}
/// <summary>The role names that may be assigned, from the applicationrole table.</summary>
[Authorize(Roles = Roles.Admin)]
[HttpGet("Roles")]
[ProducesResponseType(typeof(List<string>), StatusCodes.Status200OK)]
public IActionResult GetRoles()
{
return Ok(_userService.GetAllRoles().Select(role => role.Name).ToList());
}
/// <summary>
/// Creates an account. This replaces the old anonymous <c>register</c> endpoint —
/// only an admin may create users now, so the very first admin has to be promoted
/// directly in the database (see Sql/2026-08-05_roles.sql).
/// </summary>
[Authorize(Roles = Roles.Admin)]
[HttpPost("AddUser")]
[ProducesResponseType(typeof(ApplicationUserDTO), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public IActionResult AddUser([FromBody]ApplicationUserDTO userDto)
{
if (userDto == null)
return BadRequest(new { message = "No user supplied" });
try
{
// save
_userService.Create(userDto, userDto.Password);
return Ok();
var created = _userService.Create(
new ApplicationUser
{
Username = userDto.Username?.Trim(),
FirstName = userDto.FirstName,
LastName = userDto.LastName
},
userDto.Password,
userDto.Roles);
return Ok(ToDto(created));
}
catch (Exception ex)
{
@@ -91,7 +148,35 @@ namespace LaDOSE.Api.Controllers
}
}
/// <summary>
/// Deletes an account. Refuses to delete the caller: since only an admin can reach
/// this, and an admin cannot remove themselves, at least one admin always survives
/// — which matters because there is no anonymous way back in any more.
/// </summary>
[Authorize(Roles = Roles.Admin)]
[HttpDelete("{id}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public IActionResult DeleteUser(int id)
{
var user = _userService.GetById(id);
if (user == null)
return NotFound(new { message = "User not found" });
if (CurrentUserId() == id)
return BadRequest(new { message = "You cannot delete your own account" });
try
{
_userService.Delete(id);
return NoContent();
}
catch (Exception ex)
{
return BadRequest(new { message = ex.Message });
}
}
}
}
@@ -77,7 +77,7 @@ namespace LaDOSE.Api.Controllers
[HttpGet("UpdateDb")]
public bool UpdateDb()
{
return _service.UpdateBooking();
return false;
}
[HttpGet("CreateChallonge/{gameId:int}/{wpEventId:int}")]
@@ -0,0 +1,41 @@
#if DEBUG
using System.Linq;
using Microsoft.AspNetCore.Mvc.ApplicationModels;
using Microsoft.AspNetCore.Mvc.Routing;
namespace LaDOSE.Api.Helpers
{
/// <summary>
/// The controllers in this project are attribute-routed but do not carry [ApiController].
/// Without it MVC never sets ApiExplorer visibility, so ApiExplorer yields no descriptions
/// and the generated OpenAPI document comes out with an empty "paths" object.
/// This convention opts the attribute-routed actions into ApiExplorer for the
/// OpenAPI/Scalar tooling only, without pulling in the [ApiController] behaviours
/// (automatic 400 responses, [FromBody] inference) that would change runtime binding.
/// </summary>
public class ApiExplorerVisibilityConvention : IControllerModelConvention
{
public void Apply(ControllerModel controller)
{
// Default the controller to hidden, then opt in action by action.
controller.ApiExplorer.IsVisible ??= false;
foreach (var action in controller.Actions)
{
if (action.ApiExplorer.IsVisible != null)
{
continue;
}
// An action with a [Route] but no verb attribute (e.g. BotEventController's
// CreateBotEvent) matches every HTTP method, so ApiExplorer reports an empty
// method and OpenAPI generation throws "Unsupported HTTP method".
// Only document actions that pin down a verb.
action.ApiExplorer.IsVisible = action.Attributes
.OfType<IActionHttpMethodProvider>()
.Any(provider => provider.HttpMethods?.Any() == true);
}
}
}
}
#endif
@@ -0,0 +1,20 @@
using System.Text;
using Microsoft.EntityFrameworkCore.Storage;
using Npgsql.EntityFrameworkCore.PostgreSQL.Storage.Internal;
namespace LaDOSE.Api;
#pragma warning disable EF1001
public class NpgsqlSqlGenerationLowercaseHelper : NpgsqlSqlGenerationHelper
{
static string ToLowerCase(string input) => input.ToLower();
public NpgsqlSqlGenerationLowercaseHelper(RelationalSqlGenerationHelperDependencies dependencies)
: base(dependencies) { }
public override string DelimitIdentifier(string identifier)
=> base.DelimitIdentifier(ToLowerCase(identifier));
public override void DelimitIdentifier(StringBuilder builder, string identifier)
=> base.DelimitIdentifier(builder, ToLowerCase(identifier));
}
#pragma warning restore EF1001
+12 -8
View File
@@ -1,8 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
<TargetFramework>net9.0</TargetFramework>
<Platforms>AnyCPU;x64</Platforms>
<LangVersion>12</LangVersion>
</PropertyGroup>
<ItemGroup>
@@ -10,13 +11,16 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="AutoMapper" Version="10.0.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="3.1.8" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="3.1.8" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="3.1.8" />
<PackageReference Include="Microsoft.VisualStudio.Web.CodeGeneration.Design" Version="3.1.4" />
<PackageReference Include="Newtonsoft.Json" Version="12.0.3" />
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="6.0.2" />
<PackageReference Include="AutoMapper" Version="13.0.1" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.12" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="8.0.12" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="8.0.1" />
<PackageReference Include="Microsoft.OpenApi" Version="1.6.17" Condition="'$(Configuration)' == 'Debug'" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.18" Condition="'$(Configuration)' == 'Debug'" />
<PackageReference Include="Scalar.AspNetCore" Version="2.16.17" Condition="'$(Configuration)' == 'Debug'" />
<PackageReference Include="Microsoft.VisualStudio.Web.CodeGeneration.Design" Version="8.0.7" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.11" />
</ItemGroup>
<ItemGroup>
+50 -20
View File
@@ -1,28 +1,20 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Security.Claims;
using System.Text;
using System.Threading.Tasks;
using LaDOSE.Business.Interface;
using LaDOSE.Business.Provider;
using LaDOSE.Business.Service;
using LaDOSE.Entity;
using LaDOSE.Entity.Context;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
//using Microsoft.AspNetCore.HttpsPolicy;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
using Pomelo.EntityFrameworkCore.MySql;
using Pomelo.EntityFrameworkCore.MySql.Infrastructure;
using AutoMapper;
using LaDOSE.Api.Helpers;
using LaDOSE.Business.Helper;
@@ -32,6 +24,11 @@ using LaDOSE.Entity.Challonge;
using LaDOSE.Entity.Wordpress;
using Result = LaDOSE.Entity.Challonge.Result;
using LaDOSE.Entity.BotEvent;
using Microsoft.EntityFrameworkCore.Storage;
using Microsoft.Extensions.Hosting;
#if DEBUG
using Scalar.AspNetCore;
#endif
namespace LaDOSE.Api
{
@@ -48,11 +45,6 @@ namespace LaDOSE.Api
public void ConfigureServices(IServiceCollection services)
{
//Fix Gentoo Issue.
var MySqlServer = this.Configuration["MySql:Server"];
var MySqlDatabase = this.Configuration["MySql:Database"];
var MySqlUser = this.Configuration["MySql:User"];
var MySqlPassword = this.Configuration["MySql:Password"];
if (Convert.ToBoolean(this.Configuration["FixGentoo"]))
{
try
@@ -67,11 +59,21 @@ namespace LaDOSE.Api
}
services.AddCors();
services.AddMvc().AddNewtonsoftJson(x =>
services.AddMvc(options =>
{
#if DEBUG
// Make the attribute-routed controllers visible to ApiExplorer so the
// OpenAPI document is actually populated. See ApiExplorerVisibilityConvention.
options.Conventions.Add(new ApiExplorerVisibilityConvention());
#endif
}).AddNewtonsoftJson(x =>
{
x.SerializerSettings.ReferenceLoopHandling = Newtonsoft.Json.ReferenceLoopHandling.Ignore;
x.SerializerSettings.MaxDepth= 4;
});
#if DEBUG
services.AddOpenApi();
#endif
// services.AddDbContextPool<LaDOSEDbContext>( // replace "YourDbContext" with the class name of your DbContext
//
// options => options.UseMySql($"Server={MySqlServer};Database={MySqlDatabase};User={MySqlUser};Password={MySqlPassword};", // replace with your Connection String
@@ -82,9 +84,7 @@ namespace LaDOSE.Api
// ));
services.AddDbContextPool<LaDOSEDbContext>(options =>
{
options.UseMySql(
$"Server={MySqlServer};Database={MySqlDatabase};User={MySqlUser};Password={MySqlPassword};",
new MariaDbServerVersion(new Version(10, 1)));
options.UseNpgsql(Configuration.GetConnectionString("DbContext")).ReplaceService<ISqlGenerationHelper,NpgsqlSqlGenerationLowercaseHelper>();
});
var key = Encoding.ASCII.GetBytes(this.Configuration["JWTTokenSecret"]);
@@ -106,6 +106,18 @@ namespace LaDOSE.Api
{
// return unauthorized if user no longer exists
context.Fail("Unauthorized");
return Task.CompletedTask;
}
// Roles are attached here, from the database, rather than being
// signed into the token: a promotion or demotion then applies to
// the caller's very next request instead of waiting 16 minutes.
if (context.Principal.Identity is ClaimsIdentity identity)
{
foreach (var role in user.Names())
{
identity.AddClaim(new Claim(identity.RoleClaimType, role));
}
}
return Task.CompletedTask;
@@ -144,6 +156,13 @@ namespace LaDOSE.Api
cfg.CreateMapTwoWay<Game, LaDOSE.DTO.GameDTO>();
cfg.CreateMapTwoWay<Todo, LaDOSE.DTO.TodoDTO>();
// Match statistics: plain POCO aggregates computed by StatisticsService,
// mapped by name (same pattern as TournamentsResult above).
cfg.CreateMap<MatchStats, LaDOSE.DTO.MatchStatsDTO>();
cfg.CreateMap<MatchCoverage, LaDOSE.DTO.MatchCoverageDTO>();
cfg.CreateMap<PlayerMatchStats, LaDOSE.DTO.PlayerMatchStatsDTO>();
cfg.CreateMap<HeadToHead, LaDOSE.DTO.HeadToHeadDTO>();
});
IMapper mapper = mapperConfig.CreateMapper();
services.AddSingleton(mapper);
@@ -163,6 +182,7 @@ namespace LaDOSE.Api
services.AddScoped<IBotEventService, BotEventService>();
services.AddScoped<IPlayerService, PlayerService>();
services.AddScoped<IStatisticsService, StatisticsService>();
services.AddTransient<IChallongeProvider>(p => new ChallongeProvider( p.GetRequiredService<IGameService>(),
p.GetRequiredService<IEventService>(),
p.GetRequiredService<IPlayerService>(),
@@ -178,7 +198,7 @@ namespace LaDOSE.Api
// This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
public void Configure(IApplicationBuilder app, IHostingEnvironment env, ILoggerFactory loggerFactory)
public void Configure(IApplicationBuilder app, IHostEnvironment env, ILoggerFactory loggerFactory)
{
//loggerFactory.AddConsole(Configuration.GetSection("Logging"));
//loggerFactory.AddDebug();
@@ -199,7 +219,17 @@ namespace LaDOSE.Api
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
app.UseEndpoints(x => x.MapControllers());
app.UseEndpoints(x =>
{
x.MapControllers();
#if DEBUG
if (env.IsDevelopment())
{
x.MapOpenApi();
x.MapScalarApiReference();
}
#endif
});
}
}
}
+6 -8
View File
@@ -4,20 +4,18 @@
"Default": "Warning"
}
},
"ConnectionStrings": {
"DbContext":"Host=kafka.local;Username=tom;Password=tom;Database=ladoseapi"
},
"CertificateSettings": {
"fileName": "localhost.pfx",
"password": "YourSecurePassword"
},
"MySql": {
"Server": "host",
"Database": "database",
"User": "User",
"Password": "Password"
},
"ApiKey": {
"ChallongeApiKey": "Challonge ApiKey"
"ChallongeApiKey": "Challonge ApiKey",
"SmashApiKey": "Smash"
},
"AllowedHosts": "*",
"AllowedHosts": "0.0.0.0",
"Port": 5000,
"JWTTokenSecret": "here goes the custom Secret key for authnetication"
}
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
namespace LaDOSE.DTO
{
@@ -9,8 +10,13 @@ namespace LaDOSE.DTO
public string FirstName { get; set; }
public string LastName { get; set; }
public string Username { get; set; }
/// <summary>Only ever read from a request; never populated on a response.</summary>
public string Password { get; set; }
/// <summary>Role names held by the user, e.g. <c>["Admin"]</c>. Empty means a plain user.</summary>
public List<string> Roles { get; set; }
public string Token { get; set; }
public DateTime Expire { get; set; }
}
+6 -1
View File
@@ -1,8 +1,13 @@
namespace LaDOSE.DTO
using System;
namespace LaDOSE.DTO
{
public class EventDTO
{
public int Id { get; set; }
public string Name { get; set; }
/// <summary>Event date, mapped by convention from Event.Date. Used for time-series charts.</summary>
public DateTime Date { get; set; }
};
}
+1 -1
View File
@@ -1,7 +1,7 @@
namespace LaDOSE.DTO
{
public class GameDTO
{
{
public int Id { get; set; }
public string Name { get; set; }
public string LongName { get; set; }
+5 -1
View File
@@ -1,8 +1,12 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
<TargetFramework>net9.0</TargetFramework>
<Platforms>AnyCPU;x64</Platforms>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
</ItemGroup>
</Project>
+55
View File
@@ -0,0 +1,55 @@
using System.Collections.Generic;
namespace LaDOSE.DTO
{
public class MatchStatsDTO
{
public MatchCoverageDTO Coverage { get; set; }
public List<PlayerMatchStatsDTO> Players { get; set; }
public List<HeadToHeadDTO> HeadToHead { get; set; }
}
public class MatchCoverageDTO
{
/// <summary>Requested event ids that actually exist.</summary>
public int Events { get; set; }
/// <summary>Tournaments (brackets) belonging to those events.</summary>
public int Brackets { get; set; }
/// <summary>Of those brackets, how many have at least one set row.</summary>
public int BracketsWithSets { get; set; }
/// <summary>Total set rows in scope, including unusable ones.</summary>
public int Sets { get; set; }
/// <summary>Sets with a determinable winner.</summary>
public int DecidedSets { get; set; }
}
public class PlayerMatchStatsDTO
{
public int PlayerId { get; set; }
/// <summary>Gamertag, falling back to Name, else "#&lt;id&gt;".</summary>
public string Player { get; set; }
/// <summary>Decided sets only. Always equals Wins + Losses.</summary>
public int Sets { get; set; }
public int Wins { get; set; }
public int Losses { get; set; }
public int GamesWon { get; set; }
public int GamesLost { get; set; }
}
public class HeadToHeadDTO
{
public int PlayerAId { get; set; }
public string PlayerA { get; set; }
public int PlayerBId { get; set; }
public string PlayerB { get; set; }
public int WinsA { get; set; }
public int WinsB { get; set; }
}
}
+2
View File
@@ -27,6 +27,8 @@ namespace LaDOSE.DTO
public List<GameDTO> Games { get; set; }
public List<ResultDTO> Results { get; set; }
public string Slug { get; set; }
}
public class ResultDTO
{
@@ -0,0 +1,11 @@
<Application xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="LaDOSE.DesktopApp.Avalonia.App"
xmlns:local="using:LaDOSE.DesktopApp.Avalonia"
RequestedThemeVariant="Dark">
<!-- "Default" ThemeVariant follows system theme variant. "Dark" or "Light" are other available options. -->
<Application.Styles>
<FluentTheme />
<StyleInclude Source="avares://Avalonia.Controls.DataGrid/Themes/Fluent.xaml"/>
</Application.Styles>
</Application>
@@ -0,0 +1,39 @@
using Avalonia;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Markup.Xaml;
using LaDOSE.DesktopApp.Avalonia.ViewModels;
using LaDOSE.DesktopApp.Avalonia.Views;
using LaDOSE.REST;
using MsBox.Avalonia;
using ReactiveUI;
using Splat;
namespace LaDOSE.DesktopApp.Avalonia;
public partial class App : Application
{
public override void Initialize()
{
AvaloniaXamlLoader.Load(this);
Locator.CurrentMutable.Register(() => new GamesView(), typeof(IViewFor<GamesViewModel>));
Locator.CurrentMutable.Register(() => new InfoView(), typeof(IViewFor<InfoViewModel>));
Locator.CurrentMutable.Register(() => new TournamentResultView(), typeof(IViewFor<TournamentResultViewModel>));
}
public override void OnFrameworkInitializationCompleted()
{
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
desktop.MainWindow = new MainWindow
{
DataContext = new MainWindowViewModel(),
};
}
base.OnFrameworkInitializationCompleted();
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 172 KiB

@@ -0,0 +1,44 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<BuiltInComInteropSupport>true</BuiltInComInteropSupport>
<ApplicationManifest>app.manifest</ApplicationManifest>
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
<LangVersion>12</LangVersion>
</PropertyGroup>
<ItemGroup>
<Folder Include="Models\"/>
<AvaloniaResource Include="Assets\**"/>
</ItemGroup>
<ItemGroup>
<PackageReference Include="Avalonia" Version="11.2.3" />
<PackageReference Include="Avalonia.Controls.DataGrid" Version="11.2.3" />
<PackageReference Include="Avalonia.Desktop" Version="11.2.3" />
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.2.3" />
<PackageReference Include="Avalonia.Fonts.Inter" Version="11.2.3" />
<!--Condition below is needed to remove Avalonia.Diagnostics package from build output in Release configuration.-->
<PackageReference Condition="'$(Configuration)' == 'Debug'" Include="Avalonia.Diagnostics" Version="11.2.3" />
<PackageReference Include="Avalonia.ReactiveUI" Version="11.2.3" />
<PackageReference Include="MessageBox.Avalonia" Version="3.2.0" />
<PackageReference Include="Microsoft.Extensions.Configuration" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="8.0.1" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\LaDOSE.DTO\LaDOSE.DTO.csproj" />
<ProjectReference Include="..\LaDOSE.REST\LaDOSE.REST.csproj" />
</ItemGroup>
<ItemGroup>
<None Update="settings.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>
@@ -0,0 +1,67 @@
using Avalonia;
using Avalonia.ReactiveUI;
using System;
using System.ComponentModel;
using System.IO;
using LaDOSE.REST;
using Microsoft.Extensions.Configuration;
using MsBox.Avalonia;
using MsBox.Avalonia.Enums;
using Splat;
// using Xilium.CefGlue;
// using Xilium.CefGlue.Common;
// using Avalonia.Visuals;
namespace LaDOSE.DesktopApp.Avalonia;
sealed class Program
{
// Initialization code. Don't use any Avalonia, third-party APIs or any
// SynchronizationContext-reliant code before AppMain is called: things aren't initialized
// yet and stuff might break.
[STAThread]
public static void Main(string[] args)
{
RegisterDependencies(Locator.CurrentMutable, Locator.Current);
var app = BuildAvaloniaApp();
app.StartWithClassicDesktopLifetime(args);
}
private static void RegisterDependencies(IMutableDependencyResolver currentMutable, IReadonlyDependencyResolver current)
{
var builder = new ConfigurationBuilder()
.AddJsonFile("settings.json", optional: true, reloadOnChange: true).Build();
var restUrl = builder["REST:Url"].ToString();
var restUser = builder["REST:User"].ToString();
var restPassword = builder["REST:Password"].ToString();
currentMutable.Register<RestService>(() =>
{
var restService = new RestService(new Uri(restUrl), restUser, restPassword);
try
{
restService.Connect(new Uri(restUrl), restUser, restPassword);
}
catch (Exception e)
{
Console.WriteLine(e);
}
return restService;
});
}
// Avalonia configuration, don't remove; also used by visual designer.
public static AppBuilder BuildAvaloniaApp()
=> AppBuilder
.Configure<App>()
.UsePlatformDetect()
.WithInterFont()
.LogToTrace()
.UseReactiveUI();
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

@@ -0,0 +1,343 @@
body {
color: #efefef;
background-color: #141415;
}
:root {
--breakpoint-xs: 0;
--breakpoint-sm: 576px;
--breakpoint-md: 768px;
--breakpoint-lg: 992px;
--breakpoint-xl: 1200px;
}
.table {
width: 100%;
margin-bottom: 1rem;
background-color: transparent;
}
.table th,
.table td {
padding: 0.75rem;
vertical-align: top;
border-top: 1px solid #dee2e6;
}
.table thead th {
vertical-align: bottom;
border-bottom: 2px solid #dee2e6;
}
.table tbody + tbody {
border-top: 2px solid #dee2e6;
}
.table .table {
background-color: #fff;
}
.table-sm th,
.table-sm td {
padding: 0.3rem;
}
.table-bordered {
border: 1px solid #dee2e6;
}
.table-bordered th,
.table-bordered td {
border: 1px solid #dee2e6;
}
.table-bordered thead th,
.table-bordered thead td {
border-bottom-width: 2px;
}
.table-borderless th,
.table-borderless td,
.table-borderless thead th,
.table-borderless tbody + tbody {
border: 0;
}
.table-striped tbody tr:nth-of-type(odd) {
background-color: rgba(0, 0, 0, 0.05);
}
.table-hover tbody tr:hover {
background-color: rgba(0, 0, 0, 0.075);
}
.table-primary,
.table-primary > th,
.table-primary > td {
background-color: #b8daff;
}
.table-hover .table-primary:hover {
background-color: #9fcdff;
}
.table-hover .table-primary:hover > td,
.table-hover .table-primary:hover > th {
background-color: #9fcdff;
}
.table-secondary,
.table-secondary > th,
.table-secondary > td {
background-color: #d6d8db;
}
.table-hover .table-secondary:hover {
background-color: #c8cbcf;
}
.table-hover .table-secondary:hover > td,
.table-hover .table-secondary:hover > th {
background-color: #c8cbcf;
}
.table-success,
.table-success > th,
.table-success > td {
background-color: #c3e6cb;
}
.table-hover .table-success:hover {
background-color: #b1dfbb;
}
.table-hover .table-success:hover > td,
.table-hover .table-success:hover > th {
background-color: #b1dfbb;
}
.table-info,
.table-info > th,
.table-info > td {
background-color: #bee5eb;
}
.table-hover .table-info:hover {
background-color: #abdde5;
}
.table-hover .table-info:hover > td,
.table-hover .table-info:hover > th {
background-color: #abdde5;
}
.table-warning,
.table-warning > th,
.table-warning > td {
background-color: #ffeeba;
}
.table-hover .table-warning:hover {
background-color: #ffe8a1;
}
.table-hover .table-warning:hover > td,
.table-hover .table-warning:hover > th {
background-color: #ffe8a1;
}
.table-danger,
.table-danger > th,
.table-danger > td {
background-color: #f5c6cb;
}
.table-hover .table-danger:hover {
background-color: #f1b0b7;
}
.table-hover .table-danger:hover > td,
.table-hover .table-danger:hover > th {
background-color: #f1b0b7;
}
.table-light,
.table-light > th,
.table-light > td {
background-color: #fdfdfe;
}
.table-hover .table-light:hover {
background-color: #ececf6;
}
.table-hover .table-light:hover > td,
.table-hover .table-light:hover > th {
background-color: #ececf6;
}
.table-dark,
.table-dark > th,
.table-dark > td {
background-color: #c6c8ca;
}
.table-hover .table-dark:hover {
background-color: #b9bbbe;
}
.table-hover .table-dark:hover > td,
.table-hover .table-dark:hover > th {
background-color: #b9bbbe;
}
.table-active,
.table-active > th,
.table-active > td {
background-color: rgba(0, 0, 0, 0.075);
}
.table-hover .table-active:hover {
background-color: rgba(0, 0, 0, 0.075);
}
.table-hover .table-active:hover > td,
.table-hover .table-active:hover > th {
background-color: rgba(0, 0, 0, 0.075);
}
.table .thead-dark th {
color: #fff;
background-color: #212529;
border-color: #32383e;
}
.table .thead-light th {
color: #495057;
background-color: #e9ecef;
border-color: #dee2e6;
}
.table-dark {
color: #fff;
background-color: #212529;
}
.table-dark th,
.table-dark td,
.table-dark thead th {
border-color: #32383e;
}
.table-dark.table-bordered {
border: 0;
}
.table-dark.table-striped tbody tr:nth-of-type(odd) {
background-color: rgba(255, 255, 255, 0.05);
}
.table-dark.table-hover tbody tr:hover {
background-color: rgba(255, 255, 255, 0.075);
}
@media (max-width: 575.98px) {
.table-responsive-sm {
display: block;
width: 100%;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
-ms-overflow-style: -ms-autohiding-scrollbar;
}
.table-responsive-sm > .table-bordered {
border: 0;
}
}
@media (max-width: 767.98px) {
.table-responsive-md {
display: block;
width: 100%;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
-ms-overflow-style: -ms-autohiding-scrollbar;
}
.table-responsive-md > .table-bordered {
border: 0;
}
}
@media (max-width: 991.98px) {
.table-responsive-lg {
display: block;
width: 100%;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
-ms-overflow-style: -ms-autohiding-scrollbar;
}
.table-responsive-lg > .table-bordered {
border: 0;
}
}
@media (max-width: 1199.98px) {
.table-responsive-xl {
display: block;
width: 100%;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
-ms-overflow-style: -ms-autohiding-scrollbar;
}
.table-responsive-xl > .table-bordered {
border: 0;
}
}
.table-responsive {
display: block;
width: 100%;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
-ms-overflow-style: -ms-autohiding-scrollbar;
}
.table-responsive > .table-bordered {
border: 0;
}
.table {
border-collapse: collapse !important;
}
.table td,
.table th {
/*background-color: #fff !important;*/
}
.table-bordered th,
.table-bordered td {
border: 1px solid #dee2e6 !important;
}
.table-dark {
color: #fff;
background-color: #212529
}
.table-dark th,
.table-dark td,
.table-dark thead th,
.table-dark tbody + tbody {
border-color: #dee2e6;
}
.table .thead-dark th {
color: inherit;
border-color: #dee2e6;
}
@@ -0,0 +1,24 @@
using System.ComponentModel;
using System.Runtime.CompilerServices;
using ReactiveUI;
namespace LaDOSE.DesktopApp.Avalonia.Utils;
public abstract class BaseViewModel : ReactiveObject, IRoutableViewModel,INotifyPropertyChanged
{
public event PropertyChangedEventHandler? PropertyChanged;
protected void RaisePropertyChanged([CallerMemberName] string? propertyName = null)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
protected BaseViewModel(IScreen hostScreen, string? urlPathSegment)
{
UrlPathSegment = urlPathSegment;
HostScreen = hostScreen;
}
public string? UrlPathSegment { get; }
public IScreen HostScreen { get; }
}
@@ -1,7 +1,7 @@
using System;
using System.Collections.Generic;
namespace LaDOSE.DesktopApp.Utils
namespace LaDOSE.DesktopApp.Avalonia.Utils
{
public static class CustomListExtension
{
@@ -14,14 +14,14 @@ namespace LaDOSE.DesktopApp.Utils
_compare = c;
}
public bool Equals(T x, T y)
public bool Equals(T? x, T? y)
{
return _compare(x, y);
}
public int GetHashCode(T obj)
{
return 0;
return obj.GetHashCode();
}
}
}
@@ -0,0 +1,101 @@
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
using System.Runtime.CompilerServices;
using LaDOSE.DesktopApp.Avalonia.Utils;
using LaDOSE.DesktopApp.Avalonia.ViewModels;
using LaDOSE.DTO;
using LaDOSE.REST;
using ReactiveUI;
using Splat;
namespace LaDOSE.DesktopApp.Avalonia.ViewModels
{
public class GamesViewModel : BaseViewModel
{
public string DisplayName => "Games";
private GameDTO _currentGame;
private List<GameDTO> _games;
private List<GameDTO> _Searchgames;
private RestService RestService { get; set; }
public GamesViewModel(IScreen screen): base(screen,"Games")
{
this.RestService = Locator.Current.GetService<RestService>();
this.Games=new List<GameDTO>();
OnInitialize();
}
void OnInitialize()
{
LoadGames();
this.CurrentGame = Games.First();
}
public void LoadGames()
{
var gameDtos = this.RestService.GetGames().OrderBy(e=>e.Order).ToList();
this.Games = gameDtos;
RaisePropertyChanged(nameof(this.Games));
}
public List<GameDTO> Games
{
get => _games;
set
{
_games = value;
RaisePropertyChanged(nameof(this.Games));
}
}
public List<GameDTO> SearchGame
{
get => _Searchgames;
set
{
_Searchgames = value;
RaisePropertyChanged(nameof(this.SearchGame));
}
}
public GameDTO CurrentGame
{
get => _currentGame;
set
{
_currentGame = value;
RaisePropertyChanged(nameof(this.CurrentGame));
}
}
public void Update()
{
this.RestService.UpdateGame(this.CurrentGame);
LoadGames();
}
public void AddGame()
{
var item = new GameDTO();
this.RestService.UpdateGame(item);
LoadGames();
}
public void DeleteGame()
{
this.RestService.DeleteGame(this.CurrentGame.Id);
LoadGames();
}
public void GetGame()
{
SearchGame = this.RestService.GetSmashGames(this.CurrentGame.LongName);
}
public bool CanDeleteGame => CurrentGame != null;
}
}
@@ -0,0 +1,16 @@
using System.ComponentModel;
using ReactiveUI;
namespace LaDOSE.DesktopApp.Avalonia.ViewModels;
public class InfoViewModel: ReactiveObject, IRoutableViewModel,INotifyPropertyChanged
{
public InfoViewModel(IScreen screen)
{
HostScreen = screen;
}
public string? UrlPathSegment => "Info";
public IScreen HostScreen { get; }
}
@@ -0,0 +1,16 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes;
namespace LaDOSE.DesktopApp.Avalonia.ViewModels;
public class MainWindowViewModel : Window
{
public RoutedViewHostViewModel RoutedViewViewHost { get; } = new();
public void CloseApp()
{
if (Application.Current != null && Application.Current.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime)
((Application.Current.ApplicationLifetime as IClassicDesktopStyleApplicationLifetime)!).Shutdown();
}
}
@@ -0,0 +1,61 @@
using System.ComponentModel;
using System.Runtime.CompilerServices;
using LaDOSE.DesktopApp.Avalonia.Utils;
using ReactiveUI;
namespace LaDOSE.DesktopApp.Avalonia.ViewModels;
public class RoutedViewHostViewModel : ReactiveObject, IScreen, INotifyPropertyChanged
{
public event PropertyChangedEventHandler? PropertyChanged;
protected void RaisePropertyChanged([CallerMemberName] string? propertyName = null)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
private string _current;
public RoutedViewHostViewModel()
{
Games = new GamesViewModel(this);
Info = new InfoViewModel(this);
Tournament = new TournamentResultViewModel(this);
Router.Navigate.Execute(Tournament);
Current = "Tournament";
}
public string Current
{
get => _current;
set
{
_current = value;
RaisePropertyChanged(nameof(Current));
}
}
public RoutingState Router { get; } = new();
public GamesViewModel Games { get; }
public InfoViewModel Info { get; }
public TournamentResultViewModel Tournament { get; }
public void ShowGames()
{
Router.Navigate.Execute(Games);
Current = "Games";
}
public void ShowInfo()
{
Router.Navigate.Execute(Info);
Current = "Info";
}
public void ShowTournament()
{
Router.Navigate.Execute(Tournament);
Current = "Tournament";
}
}
@@ -1,5 +1,4 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Data;
@@ -8,70 +7,64 @@ using System.Linq;
using System.Reflection;
using System.Text;
using System.Text.RegularExpressions;
using System.Windows.Controls;
using System.Windows.Forms;
using LaDOSE.DesktopApp.Utils;
using Avalonia.Collections;
using Avalonia.Controls;
using LaDOSE.DesktopApp.Avalonia.Utils;
using LaDOSE.DTO;
using LaDOSE.REST;
using SaveFileDialog = Microsoft.Win32.SaveFileDialog;
using Screen = Caliburn.Micro.Screen;
using ReactiveUI;
using Splat;
namespace LaDOSE.DesktopApp.ViewModels
namespace LaDOSE.DesktopApp.Avalonia.ViewModels
{
public class TournamentResultViewModel : Screen
public class TournamentResultViewModel : BaseViewModel
{
public override string DisplayName => "Tournament Result";
public string DisplayName => "Tournament Result";
private RestService RestService { get; set; }
private RestService? RestService { get; set; }
//Dictionary<string, Dictionary<int, int>> _computedResult;
#region Properties
private string css = string.Empty;
//"strong { font-weight: 700;} ". +
// "a { color: #ff9024;}"+
// "body { color: #efefef;background-color: #141415; }" +
// ""+
// "a:hover, .entry-meta span a:hover, .comments-link a:hover, body.coldisplay2 #front-columns a:active {color: #cb5920;}"+
// "tr td { border: 1px dashed #3D3D3D;} ";
private String _selectRegex;
private string? _selectRegex;
public String SelectRegex
public string? SelectRegex
{
get { return _selectRegex; }
set
{
_selectRegex = value;
NotifyOfPropertyChange(() => SelectRegex);
RaisePropertyChanged(nameof(SelectRegex));
}
}
private String _selectEventRegex;
private string? _selectEventRegex;
public String SelectEventRegex
public string? SelectEventRegex
{
get { return _selectEventRegex; }
set
{
_selectEventRegex = value;
NotifyOfPropertyChange(() => SelectEventRegex);
RaisePropertyChanged(nameof(SelectEventRegex));
}
}
private string _slug;
public String Slug
private string? _slug;
public string? Slug
{
get { return _slug; }
set
{
_slug = value;
NotifyOfPropertyChange(() => Slug);
RaisePropertyChanged(nameof(Slug));
}
}
private String _html;
private string? _html;
public String Html
public string? Html
{
get { return $"<html><head><style>{this.css}</style></head><body>{HtmlContent}</body></html>"; }
set
@@ -79,58 +72,58 @@ namespace LaDOSE.DesktopApp.ViewModels
_html = value;
}
}
private String _htmlContent;
private string? _htmlContent;
public String HtmlContent
public string? HtmlContent
{
get { return _htmlContent; }
set
{
_htmlContent = value;
NotifyOfPropertyChange(() => HtmlContent);
NotifyOfPropertyChange(() => Html);
RaisePropertyChanged(nameof(HtmlContent));
RaisePropertyChanged(nameof(Html));
}
}
private DateTime _from;
private DateTimeOffset _from;
public DateTime From
public DateTimeOffset From
{
get { return _from; }
set
{
_from = value;
NotifyOfPropertyChange(() => From);
RaisePropertyChanged(nameof(From));
}
}
private DateTime _to;
private DateTimeOffset _to;
public DateTime To
public DateTimeOffset To
{
get { return _to; }
set
{
_to = value;
NotifyOfPropertyChange(() => To);
RaisePropertyChanged(nameof(To));
}
}
private TournamentsResultDTO _results;
private TournamentsResultDTO? _results;
public List<TournamentDTO> Tournaments { get; set; }
public List<EventDTO> Events { get; set; }
public TournamentsResultDTO Results
public TournamentsResultDTO? Results
{
get => _results;
set
{
_results = value;
NotifyOfPropertyChange(() => Results);
RaisePropertyChanged(nameof(Results));
}
}
@@ -142,7 +135,7 @@ namespace LaDOSE.DesktopApp.ViewModels
set
{
_selectedEvents = value;
NotifyOfPropertyChange(() => SelectedEvents);
RaisePropertyChanged(nameof(SelectedEvents));
}
}
@@ -154,14 +147,14 @@ namespace LaDOSE.DesktopApp.ViewModels
set
{
_selectedTournaments = value;
NotifyOfPropertyChange(() => SelectedTournaments);
RaisePropertyChanged(nameof(SelectedTournaments));
}
}
private GameDTO _selectedGame;
private GameDTO? _selectedGame;
public GameDTO SelectedGame
public GameDTO? SelectedGame
{
get { return _selectedGame; }
set
@@ -175,136 +168,151 @@ namespace LaDOSE.DesktopApp.ViewModels
SelectedGameResult = new ObservableCollection<ResultDTO>(resultForGame);
}
NotifyOfPropertyChange(() => SelectedGame);
RaisePropertyChanged(nameof(SelectedGame));
}
}
private ObservableCollection<ResultDTO> _selectedGameResult;
private ObservableCollection<ResultDTO>? _selectedGameResult;
public ObservableCollection<ResultDTO> SelectedGameResult
public ObservableCollection<ResultDTO>? SelectedGameResult
{
get { return _selectedGameResult; }
set
{
_selectedGameResult = value;
NotifyOfPropertyChange(() => SelectedGameResult);
RaisePropertyChanged(nameof(SelectedGameResult));
}
}
private String _first;
private DataTable _gridDataTable;
private string? _first;
private DataTable? _gridDataTable;
private string? _error;
public String First
public string? First
{
get { return _first; }
set
{
_first = value;
NotifyOfPropertyChange(() => First);
RaisePropertyChanged(nameof(First));
}
}
#endregion
public TournamentResultViewModel(RestService restService)
public TournamentResultViewModel(IScreen hostScreen):base(hostScreen,"Tournament")
{
this.RestService = restService;
this.RestService = Locator.Current.GetService<RestService>();;
_selectedTournaments = new ObservableCollection<TournamentDTO>();
_selectedEvents = new ObservableCollection<EventDTO>();
Tournaments = new List<TournamentDTO>();
Events = new List<EventDTO>();
OnInitialize();
}
protected override void OnInitialize()
protected void OnInitialize()
{
var manifestResourceStream = Assembly.GetExecutingAssembly().GetManifestResourceStream("LaDOSE.DesktopApp.Resources.css.css");
using (var sr = new StreamReader(manifestResourceStream))
{
this.css = sr.ReadToEnd();
}
// var manifestResourceStream = Assembly.GetExecutingAssembly().GetManifestResourceStream("LaDOSE.DesktopApp.Resources.css.css");
// using (var sr = new StreamReader(manifestResourceStream))
// {
// this.css = sr.ReadToEnd();
// }
this.To = DateTime.Now;
this.From = DateTime.Now.AddMonths(-1);
this.To = new DateTimeOffset(DateTime.Now);
this.From = new DateTimeOffset(DateTime.Now.AddMonths(-1));
this.SelectRegex = "Ranking";
this.SelectEventRegex = @"Ranking #10\d{2}";
this.Slug = "ranking-1001";
this.SelectEventRegex = @"Ranking #13\d{2}";
this.Slug = "ranking-130";
LoadTournaments();
LoadEvents();
base.OnInitialize();
}
public void LoadTournaments()
{
WpfUtil.Await(() =>
{
var tournamentDtos = this.RestService
.GetTournaments(new TimeRangeDTO() {From = this.From, To = this.To}).ToList();
this.Tournaments = tournamentDtos;
NotifyOfPropertyChange("Tournaments");
});
// var tournamentDtos = this.RestService
// .GetTournaments(new TimeRangeDTO() {From = this.From, To = this.To}).ToList();
// this.Tournaments = tournamentDtos;
RaisePropertyChanged(nameof(Tournaments));
}
public void LoadEvents()
{
WpfUtil.Await(() =>
{
var eventsDtos = this.RestService
List<EventDTO> eventsDtos = this.RestService
.GetAllEvents().ToList();
this.Events = eventsDtos;
NotifyOfPropertyChange("Events");
});
RaisePropertyChanged(nameof(Events));
}
public DataTable GridDataTable
public DataTable? GridDataTable
{
get => _gridDataTable;
set
{
_gridDataTable = value;
NotifyOfPropertyChange(() => GridDataTable);
RaisePropertyChanged(nameof(GridDataTable));
RaisePropertyChanged(nameof(GridDataTableView));
}
}
public DataView? GridDataTableView
{
get
{
DataView gridDataTableView = _gridDataTable?.AsDataView();
return gridDataTableView;
}
}
public void Select()
{
WpfUtil.Await(() =>
{
var tournamentsIds = SelectedEvents.Select(e => e.Id).ToList();
var resultsDto = this.RestService.GetResults(tournamentsIds);
List<int> tournamentsIds = SelectedEvents.Select(e => e.Id).ToList();
TournamentsResultDTO? resultsDto = this.RestService.GetResults(tournamentsIds);
this.Results = resultsDto;
ComputeDataGrid();
ComputeHtml();
});
}
public void GetSmash()
{
WpfUtil.Await(() =>
{
var resultsDto = this.RestService.ParseSmash(Slug);
bool resultsDto = this.RestService.ParseSmash(Slug);
if (!resultsDto)
{
MessageBox.Show("Fail");
Error = "Error getting Smash";
}
});
}
public string? Error
{
get => _error;
set
{
if (value == _error) return;
_error = value;
RaisePropertyChanged();
}
}
public void GetChallonge()
{
WpfUtil.Await(() =>
{
var ids = SelectedTournaments.Select(e => e.ChallongeId).ToList();
var resultsDto = this.RestService.ParseChallonge(ids);
List<int> ids = SelectedTournaments.Select(e => e.ChallongeId).ToList();
bool resultsDto = this.RestService.ParseChallonge(ids);
if (!resultsDto)
{
MessageBox.Show("Fail");
Error = "Fail";
}
});
}
public void UpdateEvent()
@@ -326,30 +334,30 @@ namespace LaDOSE.DesktopApp.ViewModels
public void SelectRegexp()
{
var selectedTournaments = this.Tournaments.Where(e => Regex.IsMatch(e.Name, this.SelectRegex)).ToList();
List<TournamentDTO> selectedTournaments = this.Tournaments.Where(e => Regex.IsMatch(e.Name, this.SelectRegex)).ToList();
this.SelectedTournaments.Clear();
if (selectedTournaments.Count > 0)
selectedTournaments.ForEach(e => this.SelectedTournaments.AddUI(e));
selectedTournaments.ForEach(e => this.SelectedTournaments.Add(e));
}
public void SelectEvent()
{
var selectedEvents = this.Events.Where(e => Regex.IsMatch(e.Name, this.SelectEventRegex)).ToList();
List<EventDTO> selectedEvents = this.Events.Where(e => Regex.IsMatch(e.Name, this.SelectEventRegex)).ToList();
this.SelectedEvents.Clear();
if (selectedEvents.Count > 0)
selectedEvents.ForEach(e => this.SelectedEvents.AddUI(e));
selectedEvents.ForEach(e => this.SelectedEvents.Add(e));
}
//This could be simplified the Dictionary was for a previous usage, but i m too lazy to rewrite it.
private void ComputeDataGrid()
{
var resultsParticipents = this.Results.Participents.Select(e=>e.Name).Distinct(new CustomListExtension.EqualityComparer<String>((a, b) => a.ToUpperInvariant()== b.ToUpperInvariant())).OrderBy(e=>e).ToList();
List<string> resultsParticipents = this.Results.Participents.Select(e=>e.Name).Distinct(new CustomListExtension.EqualityComparer<String>((a, b) => a.ToUpperInvariant()== b.ToUpperInvariant())).OrderBy(e=>e).ToList();
//At start the dictionnary was for some fancy dataviz things, but since the point are inside
//i m to lazy to rewrite this functions (this is so ugly...)
//_computedResult = ResultsToDataDictionary(resultsParticipents);
StringBuilder sb = new StringBuilder();
DataTable grid = new DataTable();
var games = Results.Games.Distinct().OrderBy(e => e.Order).ToList();
DataTable? grid = new DataTable();
List<GameDTO> games = Results.Games.Distinct().OrderBy(e => e.Order).ToList();
grid.Columns.Add("Players");
games.ForEach(e => grid.Columns.Add(e.Name.Replace('.', ' '),typeof(Int32)));
grid.Columns.Add("Total").DataType = typeof(Int32);
@@ -357,17 +365,18 @@ namespace LaDOSE.DesktopApp.ViewModels
for (int i = 0; i < resultsParticipents.Count; i++)
{
var dataRow = grid.Rows.Add();
var resultsParticipent = resultsParticipents[i];
DataRow dataRow = grid.Rows.Add();
string resultsParticipent = resultsParticipents[i];
int total = 0;
dataRow["Players"] = resultsParticipent;
for (int j = 0; j < games.Count; j++)
{
var resultsGame = Results.Games[j];
var points = GetPlayerPoint(resultsParticipent, resultsGame.Id);
dataRow[resultsGame.Name.Replace('.', ' ')] = points!=0?(object) points:DBNull.Value;
GameDTO? resultsGame = Results.Games[j];
int points = GetPlayerPoint(resultsParticipent, resultsGame.Id);
var o = dataRow[resultsGame.Name.Replace('.', ' ')];
dataRow[resultsGame.Name.Replace('.', ' ')] = points!=0?points:0;
total += points;
}
dataRow["Total"] = total;
@@ -389,33 +398,33 @@ namespace LaDOSE.DesktopApp.ViewModels
private void ExportToCSV()
{
if (this.GridDataTable != null)
{
var dataTable = this.GridDataTable.DefaultView.ToTable();
SaveFileDialog sfDialog = new SaveFileDialog()
{
Filter = "Csv Files (*.csv)|*.csv|All Files (*.*)|*.*",
AddExtension = true
};
if (sfDialog.ShowDialog() == true)
{
StringBuilder sb = new StringBuilder();
IEnumerable<string> columnNames = dataTable.Columns.Cast<DataColumn>()
.Select(column => column.ColumnName);
sb.AppendLine(string.Join(";", columnNames));
foreach (DataRow row in dataTable.Rows)
{
//EXCEL IS A BITCH
IEnumerable<string> fields = row.ItemArray.Select(field =>
string.Concat("\"", field.ToString().Replace("\"", "\"\""), "\""));
sb.AppendLine(string.Join(";", fields));
}
File.WriteAllText(sfDialog.FileName, sb.ToString());
}
}
// if (this.GridDataTable != null)
// {
// var dataTable = this.GridDataTable.DefaultView.ToTable();
// SaveFileDialog sfDialog = new SaveFileDialog()
// {
// Filter = "Csv Files (*.csv)|*.csv|All Files (*.*)|*.*",
// AddExtension = true
// };
// if (sfDialog.ShowDialog() == true)
// {
// StringBuilder sb = new StringBuilder();
//
// IEnumerable<string> columnNames = dataTable.Columns.Cast<DataColumn>()
// .Select(column => column.ColumnName);
// sb.AppendLine(string.Join(";", columnNames));
//
// foreach (DataRow row in dataTable.Rows)
// {
// //EXCEL IS A BITCH
// IEnumerable<string> fields = row.ItemArray.Select(field =>
// string.Concat("\"", field.ToString().Replace("\"", "\"\""), "\""));
// sb.AppendLine(string.Join(";", fields));
// }
//
// File.WriteAllText(sfDialog.FileName, sb.ToString());
// }
// }
}
private void ComputeHtml()
@@ -426,10 +435,10 @@ namespace LaDOSE.DesktopApp.ViewModels
int columns = 0;
var distinct = Results.Results.Select(e => e.GameId).Distinct();
IEnumerable<int> distinct = Results.Results.Select(e => e.GameId).Distinct();
var gamePlayed = Results.Games.Where(e=> distinct.Contains(e.Id)).OrderBy(e=>e.Order);
foreach (var game in gamePlayed)
IOrderedEnumerable<GameDTO> gamePlayed = Results.Games.Where(e=> distinct.Contains(e.Id)).OrderBy(e=>e.Order);
foreach (GameDTO game in gamePlayed)
{
List<ResultDTO> enumerable = Results.Results.Where(r => r.GameId == game.Id).ToList();
List<string> top3 = enumerable.OrderBy(e => e.Rank).Take(3).Select(e => e.Player).ToList();
@@ -443,7 +452,7 @@ namespace LaDOSE.DesktopApp.ViewModels
sb.Append("<tr>");
}
columns++;
var span = 1;
int span = 1;
if (columns == gamePlayed.Count())
{
if (columns % 2 != 0)
@@ -460,10 +469,10 @@ namespace LaDOSE.DesktopApp.ViewModels
{
sb.AppendLine($"<br> 1/ {top3[0]}<br> 2/ {top3[1]}<br> 3/ {top3[2]} <br>");
//<a href=\"https://challonge.com/fr/{enumerable.First().TournamentUrl}\" target=\"_blank\">https://challonge.com/fr/{enumerable.First().TournamentUrl}</a>
var url = enumerable.FirstOrDefault().TournamentUrl;
string url = enumerable.FirstOrDefault()?.TournamentUrl;
url = url.Replace(" ", "-");
url = url.Replace(".", "-");
sb.AppendLine($"<a href=\"https://smash.gg/tournament/ranking-1002/event/{url}\" target=\"_blank\">Voir le Bracket</p></td>");
sb.AppendLine($"<a href=\"https://start.gg/tournament/{Results.Slug}/event/{url}\" target=\"_blank\">Voir le Bracket</p></td>");
}
@@ -483,7 +492,7 @@ namespace LaDOSE.DesktopApp.ViewModels
}
public void CopyHtml()
{
System.Windows.Clipboard.SetText(this.HtmlContent);
// System.Windows.Clipboard.SetText(this.HtmlContent);
}
private int GetPlayerPoint(string name, int gameid)
@@ -0,0 +1,92 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="650"
x:Class="LaDOSE.DesktopApp.Avalonia.Views.GamesView"
xmlns:vm="using:LaDOSE.DesktopApp.Avalonia.ViewModels"
xmlns:dto="clr-namespace:LaDOSE.DTO;assembly=LaDOSE.DTO"
x:DataType="vm:GamesViewModel"
>
<Grid Row="4" Column="1">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"></RowDefinition>
<RowDefinition Height="*"></RowDefinition>
<RowDefinition Height="*"></RowDefinition>
<RowDefinition Height="Auto"></RowDefinition>
</Grid.RowDefinitions>
<Button Grid.Row="0" x:Name="LoadGames" Command="{Binding LoadGames}" >Load Games</Button>
<ListBox Grid.Row="1" ItemsSource="{Binding Games}" x:Name="GamesListView" SelectedItem="{Binding CurrentGame}">
<ListBox.ItemTemplate>
<DataTemplate>
<StackPanel Orientation="Horizontal">
<Label Content="{Binding Order}"></Label>
<Label> - </Label>
<Label Content="{Binding Name}"></Label>
</StackPanel>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<Grid Grid.Row="2">
<Grid.ColumnDefinitions>
<ColumnDefinition></ColumnDefinition>
<ColumnDefinition></ColumnDefinition>
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"></RowDefinition>
<RowDefinition Height="Auto"></RowDefinition>
<RowDefinition Height="Auto"></RowDefinition>
<RowDefinition Height="Auto"></RowDefinition>
<RowDefinition Height="Auto"></RowDefinition>
<RowDefinition Height="Auto"></RowDefinition>
<RowDefinition Height="Auto"></RowDefinition>
<RowDefinition Height="Auto"></RowDefinition>
<RowDefinition Height="*"></RowDefinition>
<RowDefinition Height="Auto"></RowDefinition>
</Grid.RowDefinitions>
<Button Grid.Row="0" Grid.Column="0" x:Name="AddGame" Command="{Binding AddGame}">Add Game</Button>
<Button Grid.Row="0" Grid.Column="1" x:Name="DeleteGame" Command="{Binding DeleteGame}">Delete Game</Button>
<Label Grid.Row="1" Grid.Column="0">Id</Label>
<TextBox Grid.Row="1" Grid.Column="1" Text="{Binding Path=CurrentGame.Id,Mode=TwoWay}" IsReadOnly="True"></TextBox>
<Label Grid.Row="2" Grid.Column="0">Name</Label>
<TextBox Grid.Row="2" Grid.Column="1" Text="{Binding Path=CurrentGame.Name,Mode=TwoWay}" ></TextBox>
<Label Grid.Row="3" Grid.Column="0">Order</Label>
<TextBox Grid.Row="3" Grid.Column="1" Text="{Binding Path=CurrentGame.Order,Mode=TwoWay}">
<!-- <i:Interaction.Behaviors> -->
<!-- <behaviors:TextBoxInputRegExBehaviour RegularExpression="^\d+$" MaxLength="9" EmptyValue="0"> -->
<!-- -->
<!-- </behaviors:TextBoxInputRegExBehaviour> -->
<!-- </i:Interaction.Behaviors> -->
</TextBox>
<Label Grid.Row="4" Grid.Column="0">LongName</Label>
<TextBox Grid.Row="4" Grid.Column="1" Text="{Binding Path=CurrentGame.LongName,Mode=TwoWay}" ></TextBox>
<Label Grid.Row="5" Grid.Column="0">WpTag</Label>
<TextBox Grid.Row="5" Grid.Column="1" Text="{Binding Path=CurrentGame.WordPressTag,Mode=TwoWay}" ></TextBox>
<Label Grid.Row="6" Grid.Column="0">WpTagOs</Label>
<TextBox Grid.Row="6" Grid.Column="1" Text="{Binding Path=CurrentGame.WordPressTagOs,Mode=TwoWay}" ></TextBox>
<Label Grid.Row="7" Grid.Column="0">SmashId</Label>
<AutoCompleteBox Grid.Row="7" Grid.Column="1" Text="{Binding Path=CurrentGame.SmashId,Mode=TwoWay}" ItemsSource="{Binding Path=SearchGame}">
<AutoCompleteBox.ItemTemplate>
<DataTemplate>
<DockPanel LastChildFill="True" Margin="2" x:DataType="dto:GameDTO">
<TextBox Text="{Binding Id}"></TextBox>
<TextBlock Text="{Binding Name}" DockPanel.Dock="Left"/>
</DockPanel>
</DataTemplate>
</AutoCompleteBox.ItemTemplate>
</AutoCompleteBox>
<Button Grid.Row="9" x:Name="Update" Command="{Binding Update}">Update</Button>
<Button Grid.Row="9" Grid.Column="1" x:Name="SmashGame" Command="{Binding GetGame}">Get Game From Smash</Button>
</Grid>
</Grid>
</UserControl>
@@ -0,0 +1,23 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
using LaDOSE.DesktopApp.Avalonia.ViewModels;
using ReactiveUI;
namespace LaDOSE.DesktopApp.Avalonia.Views;
public partial class GamesView : UserControl,IViewFor<GamesViewModel>
{
public GamesView()
{
InitializeComponent();
}
object? IViewFor.ViewModel
{
get => ViewModel;
set => ViewModel = (GamesViewModel?)value;
}
public GamesViewModel? ViewModel { get; set; }
}
@@ -0,0 +1,8 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="LaDOSE.DesktopApp.Avalonia.Views.InfoView">
<Decorator x:Name="browserWrapper"/>
</UserControl>
@@ -0,0 +1,24 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
using LaDOSE.DesktopApp.Avalonia.ViewModels;
using ReactiveUI;
namespace LaDOSE.DesktopApp.Avalonia.Views;
public partial class InfoView : UserControl, IViewFor<InfoViewModel>
{
// private AvaloniaCefBrowser browser;
public InfoView()
{
InitializeComponent();
}
object? IViewFor.ViewModel
{
get => ViewModel;
set => ViewModel = (InfoViewModel?)value;
}
public InfoViewModel? ViewModel { get; set; }
}
@@ -0,0 +1,52 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:LaDOSE.DesktopApp.Avalonia.ViewModels"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:reactiveUi="http://reactiveui.net"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="LaDOSE.DesktopApp.Avalonia.Views.MainWindow"
x:DataType="vm:MainWindowViewModel"
Icon="/Assets/avalonia-logo.ico"
Title="LaDOSE.DesktopApp.Avalonia">
<Design.DataContext>
<!-- This only sets the DataContext for the previewer in an IDE,
to set the actual DataContext for runtime, set the DataContext property in code (look at App.axaml.cs) -->
<vm:MainWindowViewModel/>
</Design.DataContext>
<Grid Row="4" Column="1">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"></RowDefinition>
<RowDefinition Height="*"></RowDefinition>
<RowDefinition Height="Auto"></RowDefinition>
</Grid.RowDefinitions>
<Menu Grid.Row="0" DockPanel.Dock="Top">
<MenuItem Header="_File">
<MenuItem Header="_Events" Command="{Binding RoutedViewViewHost.ShowTournament}">
</MenuItem>
<MenuItem Header="_Games" Command="{Binding RoutedViewViewHost.ShowGames}" >
</MenuItem>
<MenuItem Header="_Tournaments">
</MenuItem>
<MenuItem Header="_EventPlayers">
</MenuItem>
<MenuItem Header="_Info" Command="{Binding RoutedViewViewHost.ShowInfo}" />
<MenuItem Header="_Close" Command="{Binding CloseApp}" />
</MenuItem>
</Menu>
<TabControl Grid.Row="1" TabStripPlacement="Left">
<TabItem Header="{Binding Path=RoutedViewViewHost.Current}">
<DockPanel DataContext="{Binding RoutedViewViewHost}">
<reactiveUi:RoutedViewHost Router="{Binding Router}"/>
</DockPanel>
</TabItem>
</TabControl>
<StackPanel Grid.Row="2" Orientation="Horizontal">
<TextBlock> User : </TextBlock>
<TextBlock Margin="5,0,0,0"></TextBlock>
</StackPanel>
</Grid>
</Window>
@@ -0,0 +1,12 @@
using Avalonia.Controls;
namespace LaDOSE.DesktopApp.Avalonia.Views;
public partial class MainWindow : Window
{
public MainWindow()
{
InitializeComponent();
}
}
@@ -0,0 +1,212 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="LaDOSE.DesktopApp.Avalonia.Views.TournamentResultView"
xmlns:vm="using:LaDOSE.DesktopApp.Avalonia.ViewModels"
x:DataType="vm:TournamentResultViewModel"
>
<Grid Row="2" Column="1">
<Grid.RowDefinitions>
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="2*" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Grid Row="0" Column="0">
<Grid.RowDefinitions>
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Grid Row="0" Column="0">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<StackPanel Grid.Row="0" Orientation="Vertical" Margin="0,0,0,5">
<Label>Date :</Label>
<StackPanel Orientation="Vertical" VerticalAlignment="Stretch">
<StackPanel Orientation="Horizontal" VerticalAlignment="Stretch">
<DatePicker SelectedDate="{Binding From}" Margin="5,0,5,0" MonthFormat="MMMM" YearFormat="yyyy" DayVisible="False">
</DatePicker>
<Button Padding="5,0,5,0" Margin="5,0,5,0" x:Name="SelectMonth" HorizontalContentAlignment="Center" Command="{Binding SelectMonth}" Width="60">Month</Button>
</StackPanel>
<StackPanel Orientation="Horizontal" VerticalAlignment="Stretch">
<DatePicker SelectedDate="{Binding To}" Margin="5,0,5,0" MonthFormat="MMMM" YearFormat="yyyy" DayVisible="False">
</DatePicker>
<Button Padding="5,0,5,0" Margin="5,0,5,0" x:Name="SelectYear" HorizontalContentAlignment="Center" Width="60" Command="{Binding SelectYear}">Year</Button>
</StackPanel>
</StackPanel>
</StackPanel>
<Button Grid.Row="1" x:Name="LoadTournaments" Command="{Binding LoadTournaments}">Update</Button>
<ListBox Grid.Row="2" ItemsSource="{Binding Tournaments}" x:Name="TournamentList" Margin="0,0,0,5"
IsTextSearchEnabled="True" TextSearch.Text="Name"
SelectedItems="{Binding SelectedTournaments}"
SelectionMode="Multiple">
<ListBox.ItemTemplate>
<DataTemplate>
<StackPanel Orientation="Horizontal">
<TextBlock Margin="5,0,0,0" Text="{Binding Name}" />
</StackPanel>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<DockPanel Grid.Row="3" Dock="Left">
<Label>Select :</Label>
<TextBox Width="200" Text="{Binding SelectRegex}"></TextBox>
<Button Padding="5,0,5,0" Margin="5,0,5,0" x:Name="SelectRegexp" Command="{Binding SelectRegexp}">Select</Button>
<Button Padding="5,0,5,0" Margin="5,0,5,0" x:Name="GetChallonge" Command="{Binding GetChallonge}">Import</Button>
</DockPanel>
</Grid>
<Grid Row="0" Column="1">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<StackPanel Grid.Row="0" Grid.Column="0" Orientation="Horizontal" Margin="0,0,0,6">
<Label> Smash Slug :</Label>
<TextBox Width="200" Text="{Binding Slug}"></TextBox>
<Button Margin="5,0,5,0" x:Name="GetSmash" Command="{Binding GetSmash}" >Import Smash Event</Button>
</StackPanel>
<Button Grid.Row="1" x:Name="UpdateEvent" Command="{Binding UpdateEvent}">Update Event</Button>
<ListBox Grid.Row="2" ItemsSource="{Binding Events}" Margin="0,0,0,5"
IsTextSearchEnabled="True" TextSearch.Text="Name" SelectionMode="Multiple"
SelectedItems="{Binding SelectedEvents}">
<ListBox.ItemTemplate>
<DataTemplate>
<StackPanel Orientation="Horizontal">
<TextBlock Text="{Binding Id}" />
<TextBlock Margin="5,0,0,0" Text="{Binding Name}" />
</StackPanel>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<DockPanel Grid.Row="3" Dock="Left">
<Label>Select :</Label>
<TextBox Width="200" Text="{Binding SelectEventRegex}"></TextBox>
<Button Padding="5,0,5,0" Margin="5,0,5,0" x:Name="SelectEvent" Command="{Binding SelectEvent}">Select</Button>
</DockPanel>
</Grid>
</Grid>
<Grid Row="3">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="2*" />
</Grid.ColumnDefinitions>
<!--<DockPanel Grid.Row="0" Grid.ColumnSpan="3" Dock="Left">
<Label>Select :</Label>
<TextBox Width="200" Text="{Binding SelectRegex}"></TextBox>
<Button Padding="5,0,5,0" Margin="5,0,5,0" x:Name="SelectRegexp">Select</Button>
<Button x:Name="Select" >Get Tournaments Result</Button>
</DockPanel>-->
<Button x:Name="Select" Grid.ColumnSpan="3" VerticalAlignment="Stretch" HorizontalAlignment="Stretch" HorizontalContentAlignment="Center" Command="{Binding Select}">Get Tournaments Result</Button>
<StackPanel Grid.Column="0" Grid.Row="1" Grid.ColumnSpan="3" Orientation="Horizontal">
<TextBlock> Game :</TextBlock>
<TextBlock Margin="5,0,0,0" Text="{Binding Results.Games.Count}" />
</StackPanel>
<ListBox Grid.Column="0" Grid.Row="2" ItemsSource="{Binding Results.Games}" Margin="5,5,5,5"
IsTextSearchEnabled="True" TextSearch.Text="Name"
SelectedItem="{Binding SelectedGame}">
<ListBox.ItemTemplate>
<DataTemplate>
<StackPanel Orientation="Horizontal">
<TextBlock Text="{Binding Id}" />
<TextBlock Margin="5,0,0,0" Text="{Binding Name}" />
</StackPanel>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<StackPanel Grid.Row="1" Grid.Column="1" Grid.ColumnSpan="2" Orientation="Horizontal">
<TextBlock> Participents :</TextBlock>
<TextBlock Margin="5,0,0,0" Text="{Binding Results.Participents.Count}" />
</StackPanel>
<ListBox Grid.Row="2" Grid.Column="1" ItemsSource="{Binding Results.Participents}" Margin="5,5,5,5"
IsTextSearchEnabled="True" TextSearch.Text="Name">
<ListBox.ItemTemplate>
<DataTemplate>
<StackPanel Orientation="Horizontal">
<TextBlock Margin="5,0,0,0" Text="{Binding Name}" />
</StackPanel>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<TabControl Grid.Row="2" Grid.Column="2">
<TabItem Header="Result">
<DataGrid x:Name="DataGrid" PropertyChanged="DataGrid_OnPropertyChanged" ItemsSource="{Binding GridDataTableView}" CanUserSortColumns="True" BorderThickness="1" BorderBrush="Gray"/>
</TabItem>
<TabItem Header="By Game">
<DockPanel>
<StackPanel Orientation="Horizontal" DockPanel.Dock="Top">
<TextBlock> Total :</TextBlock>
<TextBlock Text="{Binding SelectedGameResult.Count}" />
</StackPanel>
<ListBox ItemsSource="{Binding SelectedGameResult}" Margin="5,5,5,5"
IsTextSearchEnabled="True" TextSearch.Text="Name" DockPanel.Dock="Top">
<ListBox.ItemTemplate>
<DataTemplate>
<StackPanel Orientation="Horizontal">
<TextBlock Margin="5,0,0,0" Text="{Binding Player}" />
<TextBlock Margin="5,0,0,0" Text="{Binding Point}" />
</StackPanel>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</DockPanel>
</TabItem>
<TabItem Header="HTML">
<DockPanel>
<Button x:Name="CopyHtml" DockPanel.Dock="Top" Command="{Binding CopyHtml}">Copy HTML to clipboard</Button>
<TextBox Text="{Binding Html}" />
</DockPanel>
</TabItem>
</TabControl>
</Grid>
<Button Grid.Column="0" Grid.Row="4" VerticalAlignment="Stretch" HorizontalAlignment="Stretch" HorizontalContentAlignment="Center" x:Name="Export" Command="{Binding Export}">Export</Button>
</Grid>
</UserControl>
@@ -0,0 +1,56 @@
using System.Data;
using System.Diagnostics;
using System.Linq;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Data;
using LaDOSE.DesktopApp.Avalonia.ViewModels;
using ReactiveUI;
namespace LaDOSE.DesktopApp.Avalonia.Views
{
/// <summary>
/// Interaction logic for ShellView.xaml
/// </summary>
public partial class TournamentResultView : UserControl, IViewFor<TournamentResultViewModel>
{
public TournamentResultView()
{
InitializeComponent();
}
object? IViewFor.ViewModel
{
get => ViewModel;
set => ViewModel = (TournamentResultViewModel)value!;
}
public TournamentResultViewModel? ViewModel { get; set; }
private void DataGrid_OnPropertyChanged(object? sender, AvaloniaPropertyChangedEventArgs e)
{
if (e.Property.Name == "ItemsSource")
{
Trace.WriteLine("Changed Binding");
var grid = (sender as DataGrid);
grid.Columns.Clear();
var data = ViewModel.GridDataTable;
foreach (DataColumn? view in data.Columns)
{
grid.Columns.Add(new DataGridTextColumn()
{
Header = view.ColumnName,
CanUserSort = true,
Binding = new Binding($"Row.ItemArray[{view.Ordinal}]")
});
}
}
return;
}
}
}
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<!-- This manifest is used on Windows only.
Don't remove it as it might cause problems with window transparency and embedded controls.
For more details visit https://learn.microsoft.com/en-us/windows/win32/sbscs/application-manifests -->
<assemblyIdentity version="1.0.0.0" name="LaDOSE.DesktopApp.Avalonia.Desktop"/>
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
<application>
<!-- A list of the Windows versions that this application has been tested on
and is designed to work with. Uncomment the appropriate elements
and Windows will automatically select the most compatible environment. -->
<!-- Windows 10 -->
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
</application>
</compatibility>
</assembly>
+7
View File
@@ -0,0 +1,7 @@
#!/usr/bin/env sh
export ANDROID_HOME=/home/tom/src/android/
export PATH=$PATH:$ANDROID_HOME/build-tools/34.0.0:$ANDROID_HOME/platforms/android-34
dotnet build LaDOSE.DesktopApp.Avalonia.csproj -p:TargetFramework=net6.0-android -p:AndroidSdkDirectory=$ANDROID_HOME/build-tools/34.0.0
@@ -0,0 +1,7 @@
{
"REST": {
"Url": "http://localhost:5000",
"User": "user",
"Password": "password"
}
}
@@ -11,7 +11,7 @@
<OutputType>WinExe</OutputType>
<RootNamespace>LaDOSE.DesktopApp</RootNamespace>
<AssemblyName>LaDOSE.DesktopApp</AssemblyName>
<TargetFrameworkVersion>v4.6.1</TargetFrameworkVersion>
<TargetFrameworkVersion>v4.7</TargetFrameworkVersion>
<FileAlignment>512</FileAlignment>
<ProjectTypeGuids>{60dc8134-eba5-43b8-bcc9-bb4bc16c2548};{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}</ProjectTypeGuids>
<WarningLevel>4</WarningLevel>
@@ -71,27 +71,6 @@
<OutputPath>bin\x64\Release\</OutputPath>
</PropertyGroup>
<ItemGroup>
<Reference Include="Caliburn.Micro, Version=3.2.0.0, Culture=neutral, PublicKeyToken=8e5891231f2ed21f, processorArchitecture=MSIL">
<HintPath>..\packages\Caliburn.Micro.Core.3.2.0\lib\net45\Caliburn.Micro.dll</HintPath>
</Reference>
<Reference Include="Caliburn.Micro.Platform, Version=3.2.0.0, Culture=neutral, PublicKeyToken=8e5891231f2ed21f, processorArchitecture=MSIL">
<HintPath>..\packages\Caliburn.Micro.3.2.0\lib\net45\Caliburn.Micro.Platform.dll</HintPath>
</Reference>
<Reference Include="Caliburn.Micro.Platform.Core, Version=3.2.0.0, Culture=neutral, PublicKeyToken=8e5891231f2ed21f, processorArchitecture=MSIL">
<HintPath>..\packages\Caliburn.Micro.3.2.0\lib\net45\Caliburn.Micro.Platform.Core.dll</HintPath>
</Reference>
<Reference Include="CefSharp, Version=103.0.120.0, Culture=neutral, PublicKeyToken=40c4b6fc221f4138, processorArchitecture=MSIL">
<HintPath>..\packages\CefSharp.Common.103.0.120\lib\net452\CefSharp.dll</HintPath>
</Reference>
<Reference Include="CefSharp.Core, Version=103.0.120.0, Culture=neutral, PublicKeyToken=40c4b6fc221f4138, processorArchitecture=MSIL">
<HintPath>..\packages\CefSharp.Common.103.0.120\lib\net452\CefSharp.Core.dll</HintPath>
</Reference>
<Reference Include="CefSharp.Wpf, Version=103.0.120.0, Culture=neutral, PublicKeyToken=40c4b6fc221f4138, processorArchitecture=MSIL">
<HintPath>..\packages\CefSharp.Wpf.103.0.120\lib\net452\CefSharp.Wpf.dll</HintPath>
</Reference>
<Reference Include="RestSharp, Version=106.11.4.0, Culture=neutral, PublicKeyToken=598062e77f915f75, processorArchitecture=MSIL">
<HintPath>..\packages\RestSharp.106.11.4\lib\net452\RestSharp.dll</HintPath>
</Reference>
<Reference Include="System" />
<Reference Include="System.Configuration" />
<Reference Include="System.Data" />
@@ -101,9 +80,6 @@
<Reference Include="System.Web" />
<Reference Include="System.Web.Extensions" />
<Reference Include="System.Windows.Forms" />
<Reference Include="System.Windows.Interactivity, Version=4.5.0.0, Culture=neutral, PublicKeyToken=31bf3856ad364e35, processorArchitecture=MSIL">
<HintPath>..\packages\Caliburn.Micro.3.2.0\lib\net45\System.Windows.Interactivity.dll</HintPath>
</Reference>
<Reference Include="System.Xml" />
<Reference Include="Microsoft.CSharp" />
<Reference Include="System.Core" />
@@ -126,8 +102,6 @@
<Compile Include="Behaviors\TextBoxInputRegExBehaviour.cs" />
<Compile Include="Behaviors\MultiSelectorBehaviours.cs" />
<Compile Include="Bootstrapper.cs" />
<Compile Include="Themes\LeftMarginMultiplierConverter.cs" />
<Compile Include="Themes\TreeViewItemExtensions.cs" />
<Compile Include="Utils\CustomEqualityCompare.cs" />
<Compile Include="Utils\PhpSerialize.cs" />
<Compile Include="UserControls\BookingUserControl.xaml.cs">
@@ -163,10 +137,6 @@
<DependentUpon>App.xaml</DependentUpon>
<SubType>Code</SubType>
</Compile>
<Page Include="Themes\Styles.xaml">
<Generator>MSBuild:Compile</Generator>
<SubType>Designer</SubType>
</Page>
<Page Include="UserControls\BookingUserControl.xaml">
<SubType>Designer</SubType>
<Generator>MSBuild:Compile</Generator>
@@ -214,7 +184,6 @@
<Generator>ResXFileCodeGenerator</Generator>
<LastGenOutput>Resources.Designer.cs</LastGenOutput>
</EmbeddedResource>
<None Include="packages.config" />
<None Include="Properties\Settings.settings">
<Generator>SettingsSingleFileGenerator</Generator>
<LastGenOutput>Settings.Designer.cs</LastGenOutput>
@@ -1,31 +0,0 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Data;
namespace DarkBlendTheme
{
public class LeftMarginMultiplierConverter : IValueConverter
{
public double Length { get; set; }
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
var item = value as TreeViewItem;
if (item == null)
return new Thickness(0);
return new Thickness(Length * item.GetDepth(), 0, 0, 0);
}
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
{
throw new System.NotImplementedException();
}
}
}
File diff suppressed because it is too large Load Diff
@@ -1,38 +0,0 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Data;
using System.Windows.Media;
namespace DarkBlendTheme
{
public static class TreeViewItemExtensions
{
public static int GetDepth(this TreeViewItem item)
{
TreeViewItem parent;
while ((parent = GetParent(item)) != null)
{
return GetDepth(parent) + 1;
}
return 0;
}
private static TreeViewItem GetParent(TreeViewItem item)
{
var parent = VisualTreeHelper.GetParent(item);
while (!(parent is TreeViewItem || parent is TreeView))
{
if (parent == null) return null;
parent = VisualTreeHelper.GetParent(parent);
}
return parent as TreeViewItem;
}
}
}
@@ -1,11 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<packages>
<package id="Caliburn.Micro" version="3.2.0" targetFramework="net461" />
<package id="Caliburn.Micro.Core" version="3.2.0" targetFramework="net461" />
<package id="cef.redist.x64" version="103.0.12" targetFramework="net461" />
<package id="cef.redist.x86" version="103.0.12" targetFramework="net461" />
<package id="CefSharp.Common" version="103.0.120" targetFramework="net461" />
<package id="CefSharp.Wpf" version="103.0.120" targetFramework="net461" />
<package id="RestSharp" version="106.11.4" targetFramework="net461" />
<package id="WPFThemes.DarkBlend" version="1.0.8" targetFramework="net461" />
</packages>
@@ -1,58 +1,55 @@
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using DSharpPlus.CommandsNext;
using DSharpPlus.CommandsNext.Attributes;
using LaDOSE.DiscordBot.Service;
using LaDOSE.DTO;
namespace LaDOSE.DiscordBot.Command
{
public class BotEvent : BaseCommandModule
{
private WebService dep;
public BotEvent(WebService d)
{
dep = d;
}
[RequireRolesAttribute(RoleCheckMode.Any, "Staff")]
[Command("newevent")]
public async Task NewEventAsync(CommandContext ctx, string command)
{
await ctx.RespondAsync(dep.RestService.CreateBotEvent(command).ToString());
}
[RequireRolesAttribute(RoleCheckMode.Any,"Staff")]
[Command("staffs")]
public async Task StaffAsync(CommandContext ctx)
{
BotEventDTO currentEvent = dep.RestService.GetLastBotEvent();
StringBuilder stringBuilder = new StringBuilder();
var present = currentEvent.Results.Where(x => x.Result).ToList();
var absent = currentEvent.Results.Where(x => !x.Result).ToList();
stringBuilder.AppendLine($"Pour {currentEvent.Name} : ");
present.ForEach(x => stringBuilder.AppendLine($":white_check_mark: {x.Name}"));
absent.ForEach(x => stringBuilder.AppendLine($":x: {x.Name}"));
await ctx.RespondAsync(stringBuilder.ToString());
}
[RequireRolesAttribute(RoleCheckMode.Any, "Staff")]
[Command("present")]
public async Task PresentAsync(CommandContext ctx)
{
await ctx.RespondAsync(dep.RestService.ResultBotEvent(new DTO.BotEventSendDTO() { DiscordId = ctx.Member.Id.ToString(), DiscordName = ctx.Member.DisplayName, Present = true }).ToString());
}
[RequireRolesAttribute(RoleCheckMode.Any, "Staff")]
[Command("absent")]
public async Task AbsentAsync(CommandContext ctx)
{
await ctx.RespondAsync(dep.RestService.ResultBotEvent(new DTO.BotEventSendDTO() { DiscordId = ctx.Member.Id.ToString(), DiscordName = ctx.Member.DisplayName, Present = false }).ToString());
}
}
// public class BotEvent : BaseCommandModule
// {
// private WebService dep;
// public BotEvent(WebService d)
// {
// dep = d;
// }
//
// [Command("newevent")]
// public async Task NewEventAsync(CommandContext ctx, string command)
// {
//
// await ctx.RespondAsync(dep.RestService.CreateBotEvent(command).ToString());
// }
// [RequireRolesAttribute(RoleCheckMode.Any,"Staff")]
// [Command("staffs")]
// public async Task StaffAsync(CommandContext ctx)
// {
// BotEventDTO currentEvent = dep.RestService.GetLastBotEvent();
// StringBuilder stringBuilder = new StringBuilder();
//
// var present = currentEvent.Results.Where(x => x.Result).ToList();
// var absent = currentEvent.Results.Where(x => !x.Result).ToList();
//
// stringBuilder.AppendLine($"Pour {currentEvent.Name} : ");
// present.ForEach(x => stringBuilder.AppendLine($":white_check_mark: {x.Name}"));
// absent.ForEach(x => stringBuilder.AppendLine($":x: {x.Name}"));
//
// await ctx.RespondAsync(stringBuilder.ToString());
//
// }
// [RequireRolesAttribute(RoleCheckMode.Any, "Staff")]
// [Command("present")]
// public async Task PresentAsync(CommandContext ctx)
// {
// await ctx.RespondAsync(dep.RestService.ResultBotEvent(new DTO.BotEventSendDTO() { DiscordId = ctx.Member.Id.ToString(), DiscordName = ctx.Member.DisplayName, Present = true }).ToString());
//
//
// }
// [RequireRolesAttribute(RoleCheckMode.Any, "Staff")]
// [Command("absent")]
// public async Task AbsentAsync(CommandContext ctx)
// {
// await ctx.RespondAsync(dep.RestService.ResultBotEvent(new DTO.BotEventSendDTO() { DiscordId = ctx.Member.Id.ToString(), DiscordName = ctx.Member.DisplayName, Present = false }).ToString());
// }
// }
}
+14 -6
View File
@@ -2,14 +2,15 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using DSharpPlus.CommandsNext;
using DSharpPlus.CommandsNext.Attributes;
using DSharpPlus.Commands;
using DSharpPlus.Commands.ArgumentModifiers;
using DSharpPlus.Commands.Processors.TextCommands;
using DSharpPlus.Entities;
namespace LaDOSE.DiscordBot.Command
{
public class Hokuto : BaseCommandModule
public class Hokuto
{
private static List<string> Games = new List<string> { "2X", "3.3", "Karnov" };
@@ -21,14 +22,21 @@ namespace LaDOSE.DiscordBot.Command
[Command("hokuto")]
public async Task HokutoUserAsync(CommandContext ctx, params DiscordMember[] user)
public async ValueTask HokutoUserAsync(TextCommandContext ctx)
{
var i = r.Next(0, 3);
if (user!=null && user.Length>0)
if (ctx.Message.MentionedUsers is { Count: 1 } )
{
await ctx.RespondAsync(ctx.User?.Mention + " vs " + user[0].Mention + " : " + Games[i].ToString());
foreach (var arg in ctx.Message.MentionedUsers)
{
if (arg is DiscordUser member)
{
await ctx.RespondAsync(ctx.User?.Mention + " vs " + member.Mention + " : " + Games[i].ToString());
return;
}
}
}
else
{
@@ -4,12 +4,11 @@ using System.Globalization;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using DSharpPlus.CommandsNext;
using DSharpPlus.CommandsNext.Attributes;
using DSharpPlus.Commands;
namespace LaDOSE.DiscordBot.Command
{
public class Public : BaseCommandModule
public class Public
{
private static List<string> Quotes { get; set; }
@@ -2,16 +2,18 @@
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net6.0</TargetFramework>
<TargetFramework>net9.0</TargetFramework>
<Platforms>AnyCPU;x64</Platforms>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="DSharpPlus" Version="4.2.0" />
<PackageReference Include="DSharpPlus.CommandsNext" Version="4.2.0" />
<PackageReference Include="DSharpPlus.Interactivity" Version="4.2.0" />
<PackageReference Include="Microsoft.Extensions.Configuration" Version="3.1.8" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="3.1.8" />
<PackageReference Include="DSharpPlus" Version="5.0.0-alpha.5" />
<PackageReference Include="DSharpPlus.Commands" Version="5.0.0-alpha.5" />
<PackageReference Include="DSharpPlus.Interactivity" Version="5.0.0-alpha.5" />
<PackageReference Include="Microsoft.Extensions.Configuration" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="8.0.1" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="8.0.2" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
</ItemGroup>
<ItemGroup>
+22 -89
View File
@@ -4,8 +4,12 @@ using System.IO;
using System.Threading;
using System.Threading.Tasks;
using DSharpPlus;
using DSharpPlus.Commands;
using DSharpPlus.Commands.Processors.SlashCommands;
using DSharpPlus.Commands.Processors.TextCommands;
using DSharpPlus.Commands.Processors.TextCommands.Parsing;
using DSharpPlus.Entities;
using DSharpPlus.Interactivity;
using DSharpPlus.CommandsNext;
using DSharpPlus.EventArgs;
using DSharpPlus.Interactivity.Extensions;
//using DSharpPlus.SlashCommands;
@@ -20,7 +24,6 @@ namespace LaDOSE.DiscordBot
{
class Program
{
static DiscordClient discord;
//static InteractivityModule Interactivity { get; set; }
static void Main(string[] args)
@@ -43,106 +46,36 @@ namespace LaDOSE.DiscordBot
var restUser = builder["REST:User"].ToString();
var restPassword = builder["REST:Password"].ToString();
var service = new ServiceCollection()
.AddSingleton(typeof(WebService), new WebService(new Uri(restUrl), restUser, restPassword))
.BuildServiceProvider();
Console.WriteLine($"LaDOSE.Net Discord Bot");
discord = new DiscordClient(new DiscordConfiguration
{
Token = discordToken,
TokenType = TokenType.Bot,
//AutoReconnect = true,
//MinimumLogLevel = LogLevel.Debug,
//MessageCacheSize = 0,
});
discord.UseInteractivity(new InteractivityConfiguration
{
// default pagination behaviour to just ignore the reactions
//PaginationBehaviour = TimeoutBehaviour.Ignore,
// default pagination timeout to 5 minutes
//PaginationTimeout = TimeSpan.FromMinutes(5),
// default timeout for other actions to 2 minutes
Timeout = TimeSpan.FromMinutes(2)
});
DiscordClientBuilder builder2 =
DiscordClientBuilder.CreateDefault(discordToken, DiscordIntents.AllUnprivileged | DiscordIntents.MessageContents | DiscordIntents.GuildMessages| TextCommandProcessor.RequiredIntents | SlashCommandProcessor.RequiredIntents);
var cts = new CancellationTokenSource();
var _cnext = discord.UseCommandsNext(new CommandsNextConfiguration()
// Setup the commands extension
builder2.UseCommands((IServiceProvider serviceProvider, CommandsExtension extension) =>
{
//CaseSensitive = false,
//EnableDefaultHelp = true,
//EnableDms = false,
//EnableMentionPrefix = true,
StringPrefixes = new List<string>() { "/", "!" },
//IgnoreExtraArguments = true,
Services = service
extension.AddCommands([typeof(Hokuto), typeof(Public)]);
TextCommandProcessor textCommandProcessor = new();
extension.AddProcessor(textCommandProcessor);
}, new CommandsConfiguration()
{
// The default value is true, however it's shown here for clarity
RegisterDefaultCommandProcessors = true,
UseDefaultCommandErrorHandler = false
// DebugGuildId = Environment.GetEnvironmentVariable("DEBUG_GUILD_ID") ?? 0,
});
DiscordClient client = builder2.Build();
//var slashCommands = discord.UseSlashCommands(new SlashCommandsConfiguration() {Services = service});
//slashCommands.RegisterCommands<SlashCommand>(guildId:null);
//_cnext.RegisterCommands<Result>();
_cnext.RegisterCommands<Public>();
//_cnext.RegisterCommands<Shutdown>();
//_cnext.RegisterCommands<Todo>();
_cnext.RegisterCommands<Hokuto>();
_cnext.RegisterCommands<BotEvent>();
foreach (var registeredCommandsKey in discord.GetCommandsNext().RegisteredCommands.Keys)
{
Console.WriteLine(registeredCommandsKey);
}
discord.Ready += (sender, eventArgs) =>
{
Console.WriteLine($"Bot READY.");
return Task.CompletedTask;
};
discord.GuildAvailable += (sender, eventArgs) =>
{
Console.WriteLine($"Joined Guild " + eventArgs.Guild.Name);
return Task.CompletedTask;
};
await discord.ConnectAsync();
// We can specify a status for our bot. Let's set it to "playing" and set the activity to "with fire".
DiscordActivity status = new("Street Fighter", DiscordActivityType.Playing);
await client.ConnectAsync(status,DiscordUserStatus.Online);
await Task.Delay(Timeout.Infinite);
//while (!cts.IsCancellationRequested)
//{
// await Task.Delay(200);
// //if(discord.GetConnectionsAsync().Result.Count)
//}
}
}
//internal class SlashCommand : ApplicationCommandModule
//{
// [SlashCommand("test", "A slash command made to test the DSharpPlusSlashCommands library!")]
// public async Task TestCommand(InteractionContext ctx)
// {
// await ctx.CreateResponseAsync("Lol");
// }
//}
}
@@ -34,13 +34,6 @@ namespace LaDOSE.DiscordBot.Service
}
private void CheckToken()
{
}
public String GetInscrits()
{
@@ -0,0 +1,17 @@
using System.ComponentModel.DataAnnotations;
namespace LaDOSE.Entity
{
/// <summary>
/// A role a user can hold, stored in the pre-existing <c>applicationrole</c> table.
/// Rows are reference data seeded by Sql/2026-08-05_roles.sql, not created at runtime.
/// </summary>
public class ApplicationRole
{
public int Id { get; set; }
[Required]
[MaxLength(50)]
public string Name { get; set; }
}
}
+10 -1
View File
@@ -1,4 +1,5 @@
using System.ComponentModel.DataAnnotations;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
@@ -14,6 +15,14 @@ namespace LaDOSE.Entity
public string Password { get; set; }
public byte[] PasswordHash { get; set; }
public byte[] PasswordSalt { get; set; }
/// <summary>
/// Rows of the <c>applicationuserrole</c> join table for this user. Only populated
/// when the query asks for it — see UserService, which includes it (and the role
/// itself) everywhere the role matters; authorization reads this on every request.
/// Prefer the <see cref="Roles.Names"/> / <see cref="Roles.IsAdmin"/> helpers.
/// </summary>
public List<ApplicationUserRole> UserRoles { get; set; }
}
}
@@ -0,0 +1,16 @@
namespace LaDOSE.Entity
{
/// <summary>
/// The <c>applicationuserrole</c> join table. Declared explicitly rather than left
/// implicit so its columns match the table that already exists in the database
/// (<c>userid</c>, <c>roleid</c>, no surrogate key).
/// </summary>
public class ApplicationUserRole
{
public int UserId { get; set; }
public ApplicationUser User { get; set; }
public int RoleId { get; set; }
public ApplicationRole Role { get; set; }
}
}
@@ -9,6 +9,7 @@ namespace LaDOSE.Entity.Challonge
public List<Game> Games{ get; set; }
public List<Result> Results { get; set; }
public string Slug { get; set; }
}
public class Result
@@ -9,6 +9,7 @@ namespace LaDOSE.Entity.Context
{
public DbSet<Game> Game { get; set; }
public DbSet<ApplicationUser> ApplicationUser { get; set; }
public DbSet<ApplicationRole> ApplicationRole { get; set; }
public DbSet<Todo> Todo { get; set; }
@@ -40,6 +41,8 @@ namespace LaDOSE.Entity.Context
{
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
@@ -47,6 +50,27 @@ namespace LaDOSE.Entity.Context
base.OnModelCreating(modelBuilder);
#region Users and roles
// Maps onto the applicationrole / applicationuserrole tables that already exist
// in the schema. The join table is configured explicitly rather than as a
// many-to-many skip navigation so its columns are exactly userid + roleid,
// with the pair as the key and no surrogate id.
modelBuilder.Entity<ApplicationUserRole>(join =>
{
join.HasKey(ur => new { ur.UserId, ur.RoleId });
join.HasOne(ur => ur.User)
.WithMany(u => u.UserRoles)
.HasForeignKey(ur => ur.UserId);
join.HasOne(ur => ur.Role)
.WithMany()
.HasForeignKey(ur => ur.RoleId);
});
#endregion
modelBuilder.Entity<Event>()
.HasMany(s => s.Tournaments);
@@ -2,11 +2,18 @@
<PropertyGroup>
<Platforms>AnyCPU;x64</Platforms>
<TargetFrameworks>net6.0;netcoreapp3.1</TargetFrameworks>
<TargetFramework>net9.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="3.1.2" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.12" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
</ItemGroup>
<ItemGroup>
<Reference Include="Microsoft.EntityFrameworkCore">
<HintPath>..\..\..\..\.nuget\packages\microsoft.entityframeworkcore\8.0.11\lib\net8.0\Microsoft.EntityFrameworkCore.dll</HintPath>
</Reference>
</ItemGroup>
</Project>
+41
View File
@@ -0,0 +1,41 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace LaDOSE.Entity
{
/// <summary>
/// The role names the API knows about. They must exist as rows in
/// <c>applicationrole</c> — see Sql/2026-08-05_roles.sql.
/// </summary>
public static class Roles
{
/// <summary>May manage user accounts.</summary>
public const string Admin = "Admin";
/// <summary>May use everything else. The absence of a role means the same thing.</summary>
public const string User = "User";
public static readonly string[] All = { Admin, User };
/// <summary>Case-insensitive, so 'admin' typed into the SQL seed still counts.</summary>
public static bool IsAdmin(this ApplicationUser user)
{
return user.Names().Any(name => Admin.Equals(name, StringComparison.OrdinalIgnoreCase));
}
/// <summary>
/// The user's role names. Empty when the user holds no role, and also when the
/// query did not include them — callers that authorize must load them.
/// </summary>
public static List<string> Names(this ApplicationUser user)
{
return user?.UserRoles?
.Select(userRole => userRole.Role?.Name)
.Where(name => !string.IsNullOrWhiteSpace(name))
.OrderBy(name => name)
.ToList()
?? new List<string>();
}
}
}
@@ -0,0 +1,74 @@
using System.Collections.Generic;
namespace LaDOSE.Entity
{
/// <summary>
/// Aggregate over the persisted <see cref="Set"/> rows of one or more Events.
/// Not an entity: it is never mapped by EF, it is computed in memory by
/// StatisticsService and mapped to MatchStatsDTO by AutoMapper (same pattern as
/// <see cref="Challonge.TournamentsResult"/>).
/// Property names must stay identical to the DTO's, the mapping is by convention.
/// </summary>
public class MatchStats
{
/// <summary>How much of the requested scope actually has set data behind it.</summary>
public MatchCoverage Coverage { get; set; } = new MatchCoverage();
public List<PlayerMatchStats> Players { get; set; } = new List<PlayerMatchStats>();
public List<HeadToHead> HeadToHead { get; set; } = new List<HeadToHead>();
}
/// <summary>
/// Set coverage is sparse: Challonge-imported events have no sets at all, and events
/// imported before SmashProvider.GetSets existed were never backfilled. These counters
/// let a caller say "12 of 40 brackets have match data" instead of implying completeness.
/// </summary>
public class MatchCoverage
{
/// <summary>Requested event ids that actually exist.</summary>
public int Events { get; set; }
/// <summary>Tournaments (brackets) belonging to those events.</summary>
public int Brackets { get; set; }
/// <summary>Of those brackets, how many have at least one set row.</summary>
public int BracketsWithSets { get; set; }
/// <summary>Total set rows in scope, including the ones skipped as unusable.</summary>
public int Sets { get; set; }
/// <summary>Sets with a determinable winner (unequal scores, two distinct real players).</summary>
public int DecidedSets { get; set; }
}
public class PlayerMatchStats
{
public int PlayerId { get; set; }
/// <summary>Gamertag, falling back to Name, else "#&lt;id&gt;".</summary>
public string Player { get; set; }
/// <summary>Decided sets only. Always equals Wins + Losses.</summary>
public int Sets { get; set; }
public int Wins { get; set; }
public int Losses { get; set; }
public int GamesWon { get; set; }
public int GamesLost { get; set; }
}
/// <summary>
/// One row per unordered pair of players with at least one decided set.
/// A is always the lower PlayerId so WinsA / WinsB are unambiguous.
/// </summary>
public class HeadToHead
{
public int PlayerAId { get; set; }
public string PlayerA { get; set; }
public int PlayerBId { get; set; }
public string PlayerB { get; set; }
public int WinsA { get; set; }
public int WinsB { get; set; }
}
}
+4 -2
View File
@@ -1,12 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
<TargetFramework>net9.0</TargetFramework>
<Platforms>AnyCPU;x64</Platforms>
<LangVersion>12</LangVersion>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="RestSharp" Version="106.11.4" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="RestSharp" Version="112.1.0" />
</ItemGroup>
<ItemGroup>
+150 -117
View File
@@ -2,9 +2,10 @@
using System.Collections.Generic;
using LaDOSE.DTO;
using LaDOSE.REST.Event;
using Newtonsoft.Json;
using RestSharp;
using RestSharp.Authenticators;
using RestSharp.Serialization.Json;
namespace LaDOSE.REST
{
@@ -23,39 +24,54 @@ namespace LaDOSE.REST
public event EventHandler<UpdatedJwtEventHandler> UpdatedJwtEvent;
public RestService() { }
public RestService()
{
}
public RestService(Uri url, string user, string password)
{
Client = new RestClient(url);
this.username = user;
this.password = password;
}
public void Connect(Uri url, string user, string password)
{
Client = new RestClient(url);
// Client = new RestClient(url);
// this.username = user;
// this.password = password;
string token;
try
{
token = GetToken(user, password);
}
catch (Exception)
{
return;
}
Client = new RestClient(url, options =>
{
#if DEBUG
Client.Timeout = 999*1000;
options.MaxTimeout = Int32.MaxValue;
#endif
this.username = user;
this.password = password;
GetToken(user, password);
options.Authenticator = new JwtAuthenticator(token);
});
}
private void GetToken(string user, string password)
private string GetToken(string user, string password)
{
var restRequest = new RestRequest("users/auth", Method.POST);
var restRequest = new RestRequest("users/auth", Method.Post);
restRequest.AddJsonBody(new {username = user, password = password});
var response = Client.Post(restRequest);
if (response.IsSuccessful)
{
JsonDeserializer d = new JsonDeserializer();
var applicationUser = d.Deserialize<ApplicationUserDTO>(response);
this.Auth = applicationUser;
Client.Authenticator = new JwtAuthenticator($"{applicationUser.Token}");
RaiseUpdatedJwtEvent(new UpdatedJwtEventHandler(this.Auth));
}
else
{
throw new Exception("unable to contact services");
}
var response = Client.Post<ApplicationUserDTO>(restRequest);
//var applicationUser = JsonConvert.DeserializeObject<ApplicationUserDTO>(response.Content);
this.Auth = response;
return response.Token;
}
private void RaiseUpdatedJwtEvent(UpdatedJwtEventHandler auth)
@@ -69,126 +85,128 @@ namespace LaDOSE.REST
private void CheckToken()
{
if (this.Auth == null || this.Auth.Expire <= DateTime.Now)
if (this.Auth == null || this.Auth.Expire.ToUniversalTime() <= DateTime.Now.ToUniversalTime())
{
GetToken(this.username,this.password);
}
}
#region PostFix
private T Post<T>(string resource,T entity)
{
var json = new RestSharp.Serialization.Json.JsonSerializer();
var jsonD = new RestSharp.Serialization.Json.JsonDeserializer();
var request = new RestRequest();
request.Method = Method.POST;
request.Resource = resource;
request.AddHeader("Accept", "application/json");
request.AddHeader("Content-type", "application/json");
request.Parameters.Clear();
request.AddParameter("application/json; charset=utf-8", json.Serialize(entity), ParameterType.RequestBody);
request.AddObject(entity);
var response = Client.Execute(request);
//var content = response.Content; // raw content as string
try
{
return jsonD.Deserialize<T>(response);
}
catch (Exception)
{
return default(T);
}
}
private R Post<P,R>(string resource, P entity)
{
var json = new RestSharp.Serialization.Json.JsonSerializer();
var jsonD = new RestSharp.Serialization.Json.JsonDeserializer();
var request = new RestRequest();
request.Method = Method.POST;
request.Resource = resource;
request.AddHeader("Accept", "application/json");
request.AddHeader("Content-type", "application/json");
request.Parameters.Clear();
request.AddParameter("application/json; charset=utf-8", json.Serialize(entity), ParameterType.RequestBody);
//request.AddObject(entity);
var response = Client.Execute(request);
//var content = response.Content; // raw content as string
try
{
return jsonD.Deserialize<R>(response);
}
catch (Exception)
{
return default(R);
}
}
#endregion
// #region PostFix
//
// private T Post<T>(string resource,T entity)
// {
// var json = new RestSharp.Serialization.Json.JsonSerializer();
// var jsonD = new RestSharp.Serialization.Json.JsonDeserializer();
// var request = new RestRequest();
// request.Method = Method.Post;
// request.Resource = resource;
// request.AddHeader("Accept", "application/json");
// request.AddHeader("Content-type", "application/json");
// request.Parameters.Clear();
// request.AddParameter("application/json; charset=utf-8", json.Serialize(entity), ParameterType.RequestBody);
// request.AddObject(entity);
// var response = Client.Execute(request);
// //var content = response.Content; // raw content as string
// try
// {
// return jsonD.Deserialize<T>(response);
// }
// catch (Exception)
// {
// return default(T);
// }
//
//
// }
// private R Post<P,R>(string resource, P entity)
// {
// var json = new RestSharp.Serialization.Json.JsonSerializer();
// var jsonD = new RestSharp.Serialization.Json.JsonDeserializer();
// var request = new RestRequest();
// request.Method = Method.Post;
// request.Resource = resource;
// request.AddHeader("Accept", "application/json");
// request.AddHeader("Content-type", "application/json");
// request.Parameters.Clear();
// request.AddParameter("application/json; charset=utf-8", json.Serialize(entity), ParameterType.RequestBody);
// //request.AddObject(entity);
// var response = Client.Execute(request);
// //var content = response.Content; // raw content as string
// try
// {
// return jsonD.Deserialize<R>(response);
// }
// catch (Exception)
// {
// return default(R);
// }
//
//
// }
//
// #endregion
#region WordPress
public List<WPEventDTO> GetEvents()
{
CheckToken();
var restRequest = new RestRequest("/api/wordpress/WPEvent", Method.GET);
var restRequest = new RestRequest("/api/wordpress/WPEvent", Method.Get);
var restResponse = Client.Get<List<WPEventDTO>>(restRequest);
return restResponse.Data;
return restResponse;
}
public WPEventDTO GetNextEvent()
{
CheckToken();
var restRequest = new RestRequest("/api/wordpress/NextEvent", Method.GET);
var restRequest = new RestRequest("/api/wordpress/NextEvent", Method.Get);
var restResponse = Client.Get<WPEventDTO>(restRequest);
return restResponse.Data;
return restResponse;
}
public string GetLastChallonge()
{
CheckToken();
var restRequest = new RestRequest($"/api/wordpress/GetLastChallonge/", Method.GET);
var restRequest = new RestRequest($"/api/wordpress/GetLastChallonge/", Method.Get);
var restResponse = Client.Get(restRequest);
return restResponse.Content;
}
public string CreateChallonge(int gameId, int eventId)
{
CheckToken();
var restRequest = new RestRequest($"/api/wordpress/CreateChallonge/{gameId}/{eventId}", Method.GET);
var restRequest = new RestRequest($"/api/wordpress/CreateChallonge/{gameId}/{eventId}", Method.Get);
var restResponse = Client.Get(restRequest);
return restResponse.Content;
}
public string CreateChallonge2(int gameId, int eventId, List<WPUserDTO> optionalPlayers)
{
CheckToken();
var restResponse = Post<List<WPUserDTO>,string>($"/api/wordpress/CreateChallonge/{gameId}/{eventId}",optionalPlayers);
RestRequest r =
new RestRequest($"/api/wordpress/CreateChallonge/{gameId}/{eventId}").AddJsonBody(optionalPlayers);
var restResponse = Client.Post<string>(r);
return restResponse;
}
public bool RefreshDb()
{
CheckToken();
var restRequest = new RestRequest("/api/Wordpress/UpdateDb", Method.GET);
var restRequest = new RestRequest("/api/Wordpress/UpdateDb", Method.Get);
var restResponse = Client.Get<bool>(restRequest);
return restResponse.Data;
return restResponse;
}
public List<WPUserDTO> GetUsers(int wpEventId, int gameId)
{
CheckToken();
var restRequest = new RestRequest($"/api/Wordpress/GetUsers/{wpEventId}/{gameId}", Method.GET);
var restRequest = new RestRequest($"/api/Wordpress/GetUsers/{wpEventId}/{gameId}", Method.Get);
var restResponse = Client.Get<List<WPUserDTO>>(restRequest);
return restResponse.Data;
return restResponse;
}
public List<WPUserDTO> GetUsersOptions(int wpEventId, int gameId)
{
CheckToken();
var restRequest = new RestRequest($"/api/Wordpress/GetUsersOptions/{wpEventId}/{gameId}", Method.GET);
var restRequest = new RestRequest($"/api/Wordpress/GetUsersOptions/{wpEventId}/{gameId}", Method.Get);
var restResponse = Client.Get<List<WPUserDTO>>(restRequest);
return restResponse.Data;
return restResponse;
}
@@ -198,20 +216,29 @@ namespace LaDOSE.REST
public List<GameDTO> GetGames()
{
CheckToken();
var restRequest = new RestRequest("/api/Game", Method.GET);
var restRequest = new RestRequest("/api/Game", Method.Get);
var restResponse = Client.Get<List<GameDTO>>(restRequest);
return restResponse.Data;
return restResponse;
}
public List<GameDTO> GetSmashGames(string name)
{
CheckToken();
var restRequest = new RestRequest($"/api/Game/Smash/{name}", Method.Get);
var restResponse = Client.Get<List<GameDTO>>(restRequest);
return restResponse;
}
public GameDTO UpdateGame(GameDTO game)
{
CheckToken();
return Post("Api/Game", game);
RestRequest r = new RestRequest("Api/Game").AddJsonBody(game);
return Client.Post<GameDTO>(r);
}
public bool DeleteGame(int gameId)
{
CheckToken();
var restRequest = new RestRequest($"/api/Game/{gameId}", Method.DELETE);
var restRequest = new RestRequest($"/api/Game/{gameId}", Method.Delete);
var restResponse = Client.Execute(restRequest);
return restResponse.IsSuccessful;
}
@@ -227,26 +254,27 @@ namespace LaDOSE.REST
public List<TodoDTO> GetTodos()
{
CheckToken();
var restRequest = new RestRequest("/api/Todo", Method.GET);
var restRequest = new RestRequest("/api/Todo", Method.Get);
var restResponse = Client.Get<List<TodoDTO>>(restRequest);
return restResponse.Data;
return restResponse;
}
public TodoDTO GetTodoById(int id)
{
CheckToken();
var restRequest = new RestRequest($"/api/Todo/{id}", Method.GET);
var restRequest = new RestRequest($"/api/Todo/{id}", Method.Get);
var restResponse = Client.Get<TodoDTO>(restRequest);
return restResponse.Data;
return restResponse;
}
public TodoDTO UpdateTodo(TodoDTO Todo)
{
CheckToken();
return Post("Api/Todo", Todo);
var restRequest = new RestRequest($"/api/Todo/", Method.Post).AddJsonBody(Todo);
return Client.Post<TodoDTO>(restRequest);
}
public bool DeleteTodo(int todoId)
{
CheckToken();
var restRequest = new RestRequest($"/api/Todo/{todoId}", Method.DELETE);
var restRequest = new RestRequest($"/api/Todo/{todoId}", Method.Delete);
var restResponse = Client.Execute(restRequest);
return restResponse.IsSuccessful;
}
@@ -258,9 +286,9 @@ namespace LaDOSE.REST
public TournamentsResultDTO Test(string test)
{
CheckToken();
var restRequest = new RestRequest($"Api/Test/Test/{test}", Method.GET);
var restRequest = new RestRequest($"Api/Test/Test/{test}", Method.Get);
var restResponse = Client.Get<TournamentsResultDTO>(restRequest);
return restResponse.Data;
return restResponse;
}
@@ -270,14 +298,17 @@ namespace LaDOSE.REST
public List<TournamentDTO> GetTournaments(TimeRangeDTO timeRange)
{
CheckToken();
List<TournamentDTO> tournamentDtos = Post<TimeRangeDTO, List<TournamentDTO>>("/api/Tournament/GetTournaments",timeRange);
RestRequest r = new RestRequest("/api/Tournament/GetTournaments").AddJsonBody(timeRange);
List<TournamentDTO> tournamentDtos = Client.Post<List<TournamentDTO>>(r);
return tournamentDtos;
}
public TournamentsResultDTO GetResults(List<int> ids)
{
CheckToken();
return Post<List<int>,TournamentsResultDTO>("Api/Tournament/GetResults", ids);
var restRequest = new RestRequest("Api/Tournament/GetResults", Method.Post).AddJsonBody(ids);
return Client.Post<TournamentsResultDTO>(restRequest);
}
@@ -285,33 +316,34 @@ namespace LaDOSE.REST
public bool ParseSmash(string slug)
{
CheckToken();
var restRequest = new RestRequest($"Api/Tournament/ParseSmash/{slug}", Method.GET);
var restRequest = new RestRequest($"Api/Tournament/ParseSmash/{slug}", Method.Get);
var restResponse = Client.Get<bool>(restRequest);
return restResponse.Data;
return restResponse;
}
public bool ParseChallonge(List<int> ids)
{
CheckToken();
return Post<List<int>, bool>("Api/Tournament/ParseChallonge", ids);
var restRequest = new RestRequest("Api/Tournament/ParseChallonge", Method.Post).AddJsonBody(ids);
return Client.Post<bool>(restRequest);
}
#endregion
#region Tournamenet Event / Player
public List<EventDTO> GetAllEvents()
{
CheckToken();
var restRequest = new RestRequest("/api/Event", Method.GET);
var restRequest = new RestRequest("/api/Event", Method.Get);
var restResponse = Client.Get<List<EventDTO>>(restRequest);
return restResponse.Data;
return restResponse;
}
public List<string> GetPlayers(string slug)
{
CheckToken();
var restRequest = new RestRequest($"/api/Tournament/GetPLayers/{slug}", Method.GET);
var restRequest = new RestRequest($"/api/Tournament/GetPLayers/{slug}", Method.Get);
var restResponse = Client.Get<List<string>>(restRequest);
return restResponse.Data;
return restResponse;
}
#endregion
@@ -321,23 +353,24 @@ namespace LaDOSE.REST
public bool CreateBotEvent(string eventName)
{
CheckToken();
var restRequest = new RestRequest($"/api/BotEvent/CreateBotEvent/{eventName}", Method.GET);
var restRequest = new RestRequest($"/api/BotEvent/CreateBotEvent/{eventName}", Method.Get);
var restResponse = Client.Get<bool>(restRequest);
return restResponse.Data;
return restResponse;
}
public BotEventDTO GetLastBotEvent()
{
CheckToken();
var restRequest = new RestRequest($"/api/BotEvent/GetLastBotEvent/", Method.GET);
var restRequest = new RestRequest($"/api/BotEvent/GetLastBotEvent/", Method.Get);
var restResponse = Client.Post<BotEventDTO>(restRequest);
return restResponse.Data;
return restResponse;
}
public bool ResultBotEvent(BotEventSendDTO result)
{
CheckToken();
return Post<BotEventSendDTO,bool>("/api/BotEvent/ResultBotEvent", result);
var restRequest = new RestRequest("/api/BotEvent/ResultBotEvent", Method.Post).AddJsonBody(result);
return Client.Post<bool>(restRequest);
}
#endregion
@@ -10,6 +10,9 @@ namespace LaDOSE.Business.Interface
{
Task<List<ChallongeTournament>> GetTournaments(DateTime? start, DateTime? end);
Task<Event> ParseSmash(string tournamentSlug);
Task<List<Game>> GetSmashGame(string name);
//Task<List<Event>> ParseChallonge(List<int> ids);
//Task<TournamentsResult> GetChallongeTournamentsResult(List<int> ids);
@@ -18,6 +18,7 @@ namespace LaDOSE.Business.Interface
Task<TournamentResponse> GetNames(string slug);
Task<List<Game>> GetGames(string name);
}
}
@@ -0,0 +1,16 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using LaDOSE.Entity;
namespace LaDOSE.Business.Interface
{
public interface IStatisticsService
{
/// <summary>
/// Aggregate the persisted sets of the given Events into per-player records,
/// head-to-head records and a coverage report.
/// A null or empty id list yields a well-formed, zeroed <see cref="MatchStats"/>.
/// </summary>
Task<MatchStats> GetMatchStats(List<int> eventIds);
}
}
@@ -8,8 +8,11 @@ namespace LaDOSE.Business.Interface
ApplicationUser Authenticate(string username, string password);
IEnumerable<ApplicationUser> GetAll();
ApplicationUser GetById(int id);
ApplicationUser Create(ApplicationUser user, string password);
ApplicationUser Create(ApplicationUser user, string password, IEnumerable<string> roleNames = null);
void Update(ApplicationUser user, string password = null);
void Delete(int id);
/// <summary>The roles that exist in the database — reference data, not created at runtime.</summary>
IEnumerable<ApplicationRole> GetAllRoles();
}
}
@@ -11,7 +11,6 @@ namespace LaDOSE.Business.Interface
List<WPEvent> GetWpEvent();
List<WPUser> GetBooking(int wpEventId, Game game);
List<WPUser> GetBookingOptions(int wpEventId, Game game);
bool UpdateBooking();
string CreateChallonge(int gameId, int wpEventId, IList<WPUser> additionPlayers);
Task<string> GetLastChallonge();
@@ -1,15 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
<TargetFramework>net9.0</TargetFramework>
<AssemblyName>LaDOSE.Business</AssemblyName>
<RootNamespace>LaDOSE.Business</RootNamespace>
<Platforms>AnyCPU;x64</Platforms>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="GraphQL.Client" Version="4.0.2" />
<PackageReference Include="GraphQL.Client.Serializer.Newtonsoft" Version="4.0.2" />
<PackageReference Include="GraphQL.Client" Version="6.1.0" />
<PackageReference Include="GraphQL.Client.Serializer.Newtonsoft" Version="6.1.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.12" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
</ItemGroup>
<ItemGroup>
@@ -15,6 +15,7 @@ namespace LaDOSE.Business.Provider.SmashProvider
{
public class SmashProvider : ISmashProvider
{
private static string API_FQDN = "api.start.gg";
public string ApiKey { get; set; }
//public SmashProvider(string apiKey)
//{
@@ -34,7 +35,7 @@ namespace LaDOSE.Business.Provider.SmashProvider
private async Task<T> QuerySmash<T>(GraphQLRequest req)
{
var graphQLClient = new GraphQLHttpClient("https://api.smash.gg/gql/alpha", new NewtonsoftJsonSerializer());
var graphQLClient = new GraphQLHttpClient($"https://{API_FQDN}/gql/alpha", new NewtonsoftJsonSerializer());
graphQLClient.HttpClient.DefaultRequestHeaders.Add("Authorization", $"Bearer {ApiKey}");
var graphQLResponse = await graphQLClient.SendQueryAsync<T>(req);
@@ -47,6 +48,37 @@ namespace LaDOSE.Business.Provider.SmashProvider
return graphQLResponse.Data;
}
public async Task<List<Game>> GetGames(string game)
{
var query = new GraphQLRequest()
{
Query = @"
query VideogameQuery($name:String) {
videogames(query: { filter: { name: $name }, perPage: 5 }) {
nodes {
id
name
displayName
}
}
}
",
OperationName = "VideogameQuery",
Variables = new
{
name = game,
}
};
VideoGamesResponse querySmash = await QuerySmash<VideoGamesResponse>(query);
if (querySmash.videogames != null)
{
return querySmash.videogames.nodes.Select(e => new Game() { Id = e.id, Name = e.Name }).ToList();
}
return new List<Game>();
}
public async Task<Event> GetEvent(string slug)
{
@@ -315,7 +347,7 @@ namespace LaDOSE.Business.Provider.SmashProvider
public async Task<TournamentResponse> GetNames(string slug)
{
var graphQLClient = new GraphQLHttpClient("https://api.smash.gg/gql/alpha", new NewtonsoftJsonSerializer());
var graphQLClient = new GraphQLHttpClient($"https://{API_FQDN}/gql/alpha", new NewtonsoftJsonSerializer());
graphQLClient.HttpClient.DefaultRequestHeaders.Add("Authorization", $"Bearer {ApiKey}");
var Event = new GraphQLRequest
{
@@ -381,7 +413,7 @@ namespace LaDOSE.Business.Provider.SmashProvider
public async Task<TournamentResponse> GetTournament(string slug)
{
var graphQLClient = new GraphQLHttpClient("https://api.smash.gg/gql/alpha", new NewtonsoftJsonSerializer());
var graphQLClient = new GraphQLHttpClient($"https://{API_FQDN}/gql/alpha", new NewtonsoftJsonSerializer());
graphQLClient.HttpClient.DefaultRequestHeaders.Add("Authorization", $"Bearer {ApiKey}");
var Event = new GraphQLRequest
{
@@ -1,4 +1,5 @@
using System;
#nullable enable
using System;
using System.Collections.Generic;
using Newtonsoft.Json;
using Newtonsoft.Json.Converters;
@@ -11,62 +12,62 @@ namespace LaDOSE.Business.Provider.SmashProvider
public int totalPages { get; set; }
public int page { get; set; }
public int perPage { get; set; }
public string sortBy { get; set; }
public string filter { get; set; }
public string? sortBy { get; set; }
public string? filter { get; set; }
}
public class TournamentType
{
public int id { get; set; }
public string Name { get; set; }
public string? Name { get; set; }
[JsonConverter(typeof(UnixDateTimeConverter))]
public DateTime startAt { get; set; }
public List<EventType> Events { get; set; }
public List<EventType>? Events { get; set; }
}
public class VideoGameType
{
public int id { get; set; }
public string Name { get; set; }
public string? Name { get; set; }
}
public class ScoreType
{
public string label { get; set; }
public string? label { get; set; }
public int? value { get; set; }
public string displayValue { get; set; }
public string? displayValue { get; set; }
}
public class StatType
{
public ScoreType score { get; set; }
public ScoreType? score { get; set; }
}
public class StandingType
{
public string id { get; set; }
public string? id { get; set; }
public int placement { get; set; }
public ParticipantType player { get; set; }
public ParticipantType? player { get; set; }
public StatType stats { get; set; }
public StatType? stats { get; set; }
public EntrantType entrant { get; set; }
public EntrantType? entrant { get; set; }
}
public class ParticipantType
{
public int id { get; set; }
public string gamerTag { get; set; }
public string? gamerTag { get; set; }
public UserType? user { get; set; }
}
public class UserType
{
public int id { get; set; }
public string name { get; set; }
public string? name { get; set; }
}
@@ -74,68 +75,71 @@ namespace LaDOSE.Business.Provider.SmashProvider
{
public int id { get; set; }
public string name { get; set; }
public string state { get; set; }
public string? name { get; set; }
public string? state { get; set; }
public VideoGameType videogame { get; set; }
public Node<StandingType> standings { get; set; }
public Node<SetType> sets { get; set; }
public VideoGameType? videogame { get; set; }
public Node<StandingType>? standings { get; set; }
public Node<SetType>? sets { get; set; }
public Node<EntrantType> entrants { get; set; }
public Node<EntrantType>? entrants { get; set; }
}
public class EntrantType
{
public int id { get; set; }
public string name { get; set; }
public string? name { get; set; }
public bool? isDisqualified { get; set; }
public List<ParticipantType> participants { get; set; }
public List<ParticipantType>? participants { get; set; }
}
public class SlotType
{
public string id { get; set; }
public string? id { get; set; }
public int slotIndex { get; set; }
public StandingType standing { get; set; }
public StandingType? standing { get; set; }
public EntrantType entrant { get; set; }
public EntrantType? entrant { get; set; }
}
public class SetType
{
public string id { get; set; }
public string? id { get; set; }
public int? lPlacement { get; set; }
public int? wPlacement { get; set; }
public int? round { get; set; }
public List<SlotType> slots { get; set; }
public string identifier { get; set; }
public List<SlotType>? slots { get; set; }
public string? identifier { get; set; }
}
public class Node<T>
{
public PageInfoType pageInfo { get; set; }
public List<T> nodes { get; set; }
public PageInfoType? pageInfo { get; set; }
public List<T>? nodes { get; set; }
}
public class VideoGamesResponse
{
public Node<VideoGameType>? videogames {get; set; }
}
public class TournamentResponse
{
public TournamentType Tournament { get; set; }
public TournamentType? Tournament { get; set; }
}
public class EventResponse
{
public EventType Event { get; set; }
public EventType? Event { get; set; }
}
public class SetsResponse
{
public EventType Event { get; set; }
public EventType? Event { get; set; }
}
@@ -6,6 +6,7 @@ using LaDOSE.Entity.BotEvent;
using LaDOSE.Entity.Context;
using Microsoft.EntityFrameworkCore;
namespace LaDOSE.Business.Service
{
public class BotEventService : BaseService<BotEvent>, IBotEventService
@@ -78,6 +78,10 @@ namespace LaDOSE.Business.Service
//}
}
public async Task<List<Game>> GetSmashGames(string name)
{
return await _smashProvider.GetGames(name);
}
public async Task<Event> ParseSmash(string tournamentSlug)
{
Event eventExist = GetBySlug(tournamentSlug);
@@ -96,7 +100,7 @@ namespace LaDOSE.Business.Service
//POKEMON.
catch (Exception e)
{
throw new Exception("FUCK !");
throw new Exception($"FUCK ! {e.Message}");
}
return currentEvent;
}
@@ -109,6 +113,11 @@ namespace LaDOSE.Business.Service
}
public Task<List<Game>> GetSmashGame(string name)
{
return _smashProvider.GetGames(name);
}
private Event GetBySlug(string tournamentSlug)
{
return _context.Event.FirstOrDefault(e => e.SmashSlug == tournamentSlug);
@@ -143,6 +152,10 @@ namespace LaDOSE.Business.Service
var games = _context.Game.ToList();
TournamentsResult result = new TournamentsResult();
if (id.Count == 1)
{
result.Slug = _context.Event.Where(e=> e.Id == id.First()).First().SmashSlug;
}
result.Results = new List<Result>();
result.Games = new List<Game>();
result.Participents = new List<ChallongeParticipent>();
@@ -0,0 +1,267 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Threading.Tasks;
using LaDOSE.Business.Interface;
using LaDOSE.Entity;
using LaDOSE.Entity.Context;
namespace LaDOSE.Business.Service
{
/// <summary>
/// Match statistics over the persisted Set rows.
///
/// Two things to know about the data:
/// - There is no Set -> Player relationship in the EF model (the navigation properties
/// and their configuration are commented out), so player names are resolved with a
/// separate query plus an in-memory dictionary. Nothing here Includes a player.
/// - There is no winner column. The winner is inferred from the scores, and start.gg
/// encodes a DQ as -1, so games are clamped at 0.
///
/// The database work and the aggregation are deliberately separated: <see cref="GetMatchStats"/>
/// issues one query per table and hands the loaded lists to the pure static
/// <see cref="Aggregate"/>, which is unit-testable without a database.
/// </summary>
public class StatisticsService : IStatisticsService
{
protected LaDOSEDbContext _context;
public StatisticsService(LaDOSEDbContext context)
{
_context = context;
}
public Task<MatchStats> GetMatchStats(List<int> eventIds)
{
var requested = (eventIds ?? new List<int>()).Distinct().ToList();
if (requested.Count == 0)
{
// Well-formed and empty, never null and never a 500.
return Task.FromResult(new MatchStats());
}
// One query per table. No query per set and no query per player.
var events = _context.Event
.Where(e => requested.Contains(e.Id))
.ToList();
var existingEventIds = events.Select(e => e.Id).ToList();
var tournaments = existingEventIds.Count == 0
? new List<Tournament>()
: _context.Tournament
.Where(t => existingEventIds.Contains(t.EventId))
.ToList();
var tournamentIds = tournaments.Select(t => t.Id).Distinct().ToList();
// _context.Set is the DbSet<Set> property, not DbContext.Set<T>().
var sets = tournamentIds.Count == 0
? new List<Set>()
: _context.Set
.Where(s => tournamentIds.Contains(s.TournamentId))
.ToList();
var playerIds = sets.Select(s => s.Player1Id)
.Concat(sets.Select(s => s.Player2Id))
.Where(id => id != 0)
.Distinct()
.ToList();
var players = playerIds.Count == 0
? new List<Player>()
: _context.Player
.Where(p => playerIds.Contains(p.Id))
.ToList();
return Task.FromResult(Aggregate(requested, events, tournaments, sets, players));
}
/// <summary>
/// Pure aggregation over already-loaded rows. No database, no I/O, deterministic.
/// </summary>
/// <param name="requestedEventIds">The event ids the caller asked for.</param>
/// <param name="events">Events that exist (used for the coverage count).</param>
/// <param name="tournaments">Candidate tournaments; filtered here on EventId.</param>
/// <param name="sets">Candidate sets; filtered here on TournamentId.</param>
/// <param name="players">Players used to resolve display names; may be incomplete.</param>
public static MatchStats Aggregate(
IEnumerable<int> requestedEventIds,
IEnumerable<Event> events,
IEnumerable<Tournament> tournaments,
IEnumerable<Set> sets,
IEnumerable<Player> players)
{
var result = new MatchStats();
var requested = new HashSet<int>((requestedEventIds ?? Enumerable.Empty<int>()));
if (requested.Count == 0)
{
return result;
}
// Coverage: events -------------------------------------------------------
var existingEventIds = new HashSet<int>((events ?? Enumerable.Empty<Event>())
.Where(e => e != null && requested.Contains(e.Id))
.Select(e => e.Id));
result.Coverage.Events = existingEventIds.Count;
// Coverage: brackets ----------------------------------------------------
var scopedTournamentIds = new HashSet<int>((tournaments ?? Enumerable.Empty<Tournament>())
.Where(t => t != null && existingEventIds.Contains(t.EventId))
.Select(t => t.Id));
result.Coverage.Brackets = scopedTournamentIds.Count;
var scopedSets = (sets ?? Enumerable.Empty<Set>())
.Where(s => s != null && scopedTournamentIds.Contains(s.TournamentId))
.ToList();
result.Coverage.Sets = scopedSets.Count;
result.Coverage.BracketsWithSets = scopedSets.Select(s => s.TournamentId).Distinct().Count();
// Name resolution -------------------------------------------------------
var nameById = new Dictionary<int, string>();
foreach (var player in (players ?? Enumerable.Empty<Player>()).Where(p => p != null))
{
nameById[player.Id] = DisplayName(player);
}
var stats = new Dictionary<int, PlayerMatchStats>();
var h2h = new Dictionary<(int, int), HeadToHead>();
foreach (var set in scopedSets)
{
// Unusable rows still count in Coverage.Sets but nowhere else.
if (set.Player1Id == 0 || set.Player2Id == 0 || set.Player1Id == set.Player2Id)
{
continue;
}
// No winner column: equal scores (including 0-0 and -1 / -1) are undecided.
if (set.Player1Score == set.Player2Score)
{
continue;
}
result.Coverage.DecidedSets++;
var p1 = set.Player1Id;
var p2 = set.Player2Id;
// A DQ is stored as -1. Clamp so it never produces negative games.
var games1 = Math.Max(0, set.Player1Score);
var games2 = Math.Max(0, set.Player2Score);
var stat1 = GetOrAdd(stats, p1, nameById);
var stat2 = GetOrAdd(stats, p2, nameById);
stat1.GamesWon += games1;
stat1.GamesLost += games2;
stat2.GamesWon += games2;
stat2.GamesLost += games1;
var winnerId = set.Player1Score > set.Player2Score ? p1 : p2;
if (winnerId == p1)
{
stat1.Wins++;
stat2.Losses++;
}
else
{
stat2.Wins++;
stat1.Losses++;
}
// Lower PlayerId is always A, so WinsA / WinsB are unambiguous.
var aId = Math.Min(p1, p2);
var bId = Math.Max(p1, p2);
var key = (aId, bId);
if (!h2h.TryGetValue(key, out var pair))
{
pair = new HeadToHead
{
PlayerAId = aId,
PlayerA = ResolveName(aId, nameById),
PlayerBId = bId,
PlayerB = ResolveName(bId, nameById)
};
h2h[key] = pair;
}
if (winnerId == aId)
{
pair.WinsA++;
}
else
{
pair.WinsB++;
}
}
// Sets is derived, so Sets == Wins + Losses cannot drift.
foreach (var stat in stats.Values)
{
stat.Sets = stat.Wins + stat.Losses;
Debug.Assert(stat.Sets == stat.Wins + stat.Losses, "Sets must equal Wins + Losses");
}
result.Players = stats.Values
.OrderByDescending(p => p.Wins)
.ThenByDescending(p => p.Sets)
.ThenBy(p => p.Player, StringComparer.Ordinal)
.ThenBy(p => p.PlayerId)
.ToList();
// Not capped: bounded by the number of sets in scope.
result.HeadToHead = h2h.Values
.OrderByDescending(p => p.WinsA + p.WinsB)
.ThenBy(p => p.PlayerA, StringComparer.Ordinal)
.ThenBy(p => p.PlayerB, StringComparer.Ordinal)
.ThenBy(p => p.PlayerAId)
.ThenBy(p => p.PlayerBId)
.ToList();
return result;
}
private static PlayerMatchStats GetOrAdd(Dictionary<int, PlayerMatchStats> stats, int playerId,
Dictionary<int, string> nameById)
{
if (!stats.TryGetValue(playerId, out var stat))
{
stat = new PlayerMatchStats
{
PlayerId = playerId,
Player = ResolveName(playerId, nameById)
};
stats[playerId] = stat;
}
return stat;
}
/// <summary>
/// Gamertag, falling back to Name, else "#&lt;id&gt;" (a set can reference a player row
/// that no longer exists, and Gamertag is nullable in the database).
/// </summary>
private static string ResolveName(int playerId, Dictionary<int, string> nameById)
{
return nameById.TryGetValue(playerId, out var name) ? name : $"#{playerId}";
}
private static string DisplayName(Player player)
{
if (!string.IsNullOrWhiteSpace(player.Gamertag))
{
return player.Gamertag;
}
if (!string.IsNullOrWhiteSpace(player.Name))
{
return player.Name;
}
return $"#{player.Id}";
}
}
}
@@ -4,6 +4,7 @@ using System.Linq;
using LaDOSE.Business.Interface;
using LaDOSE.Entity;
using LaDOSE.Entity.Context;
using Microsoft.EntityFrameworkCore;
namespace LaDOSE.Business.Service
{
@@ -20,7 +21,9 @@ namespace LaDOSE.Business.Service
{
if (string.IsNullOrEmpty(username) || string.IsNullOrEmpty(password))
return null;
var user = _context.ApplicationUser.SingleOrDefault(x => x.Username == username);
var user = _context.ApplicationUser
.Include(x => x.UserRoles).ThenInclude(ur => ur.Role)
.SingleOrDefault(x => x.Username == username);
// check if username exists
if (user == null)
@@ -36,23 +39,44 @@ namespace LaDOSE.Business.Service
public IEnumerable<ApplicationUser> GetAll()
{
return _context.ApplicationUser;
return _context.ApplicationUser
.Include(x => x.UserRoles).ThenInclude(ur => ur.Role)
.ToList();
}
/// <summary>
/// Roles are included because authorization reads them on every authenticated
/// request (see the OnTokenValidated handler in Startup).
/// </summary>
public ApplicationUser GetById(int id)
{
return _context.ApplicationUser.Find(id);
return _context.ApplicationUser
.Include(x => x.UserRoles).ThenInclude(ur => ur.Role)
.SingleOrDefault(x => x.Id == id);
}
public ApplicationUser Create(ApplicationUser user, string password)
public IEnumerable<ApplicationRole> GetAllRoles()
{
return _context.ApplicationRole.OrderBy(x => x.Name).ToList();
}
public ApplicationUser Create(ApplicationUser user, string password, IEnumerable<string> roleNames = null)
{
// validation
if (string.IsNullOrWhiteSpace(user?.Username))
throw new Exception("Username is required");
if (string.IsNullOrWhiteSpace(password))
throw new Exception("Password is required");
if (_context.ApplicationUser.Any(x => x.Username == user.Username))
throw new Exception("Username \"" + user.Username + "\" is already taken");
// EF fills userid/roleid on the join rows from these navigations when it saves.
user.UserRoles = ResolveRoles(roleNames)
.Select(role => new ApplicationUserRole { Role = role })
.ToList();
byte[] passwordHash, passwordSalt;
CreatePasswordHash(password, out passwordHash, out passwordSalt);
@@ -65,6 +89,35 @@ namespace LaDOSE.Business.Service
return user;
}
/// <summary>
/// Turns role names into the existing rows of <c>applicationrole</c>. An unknown
/// name is an error rather than a new role, so a typo cannot quietly produce an
/// account with no privileges — or, worse, a second spelling of "Admin".
/// </summary>
private List<ApplicationRole> ResolveRoles(IEnumerable<string> roleNames)
{
var wanted = (roleNames ?? Enumerable.Empty<string>())
.Where(name => !string.IsNullOrWhiteSpace(name))
.Select(name => name.Trim())
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
if (wanted.Count == 0)
return new List<ApplicationRole>();
var known = _context.ApplicationRole.ToList();
var resolved = new List<ApplicationRole>();
foreach (var name in wanted)
{
var role = known.FirstOrDefault(r => string.Equals(r.Name, name, StringComparison.OrdinalIgnoreCase));
if (role == null)
throw new Exception($"Unknown role \"{name}\"");
resolved.Add(role);
}
return resolved;
}
public void Update(ApplicationUser userParam, string password = null)
{
var user = _context.ApplicationUser.Find(userParam.Id);
@@ -100,12 +153,18 @@ namespace LaDOSE.Business.Service
public void Delete(int id)
{
var user = _context.ApplicationUser.Find(id);
if (user != null)
{
_context.ApplicationUser.Remove(user);
_context.SaveChanges();
}
// applicationuserrole's foreign keys are ON DELETE RESTRICT, so the join rows
// have to go first — clearing the collection makes EF delete them.
var user = _context.ApplicationUser
.Include(x => x.UserRoles).ThenInclude(ur => ur.Role)
.SingleOrDefault(x => x.Id == id);
if (user == null)
return;
user.UserRoles?.Clear();
_context.ApplicationUser.Remove(user);
_context.SaveChanges();
}
// private helper methods
@@ -43,13 +43,6 @@ namespace LaDOSE.Business.Service
return wpEvents;
}
public bool UpdateBooking()
{
_context.Database.SetCommandTimeout(60);
_context.Database.ExecuteSqlCommand("call ladoseapi.ImportEvent();");
_context.Database.SetCommandTimeout(30);
return true;
}
public List<WPUser> GetBooking(int wpEventId, Game game)
{
var selectedGameWpId = game.WordPressTag.Split(';');
+21
View File
@@ -0,0 +1,21 @@
# Keep the build context small and free of local state.
node_modules
build
.svelte-kit
.git
.vscode
# Real URLs live here and must never reach the image. .env.example is documentation.
.env
.env.*
!.env.example
# Consumed by `podman build` itself, never copied in.
Dockerfile
.dockerignore
# Local noise
.DS_Store
Thumbs.db
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
+16
View File
@@ -0,0 +1,16 @@
# Base URL of LaDOSE.Api. Defaults to http://localhost:5000 when unset,
# which matches the Kestrel binding the API uses in development.
#
# Read by `vite dev` and inlined at build time (`npm run build`, or the Dockerfile's
# `--build-arg VITE_API_BASE_URL=...`). Baking it is optional.
VITE_API_BASE_URL=http://localhost:5000
# Container runtime only, and the reason one image serves every environment:
# docker-entrypoint.sh turns this into /config.js on start, which app.html loads
# before the bundle. Not a Vite variable, so it does NOT belong in a .env file.
#
# podman run -e LADOSE_API_BASE_URL=https://api.ladose.net ladose-webapp
#
# Precedence: /config.js > baked VITE_API_BASE_URL > http://localhost:5000.
# Leave it unset and /config.js is `{}`, so the baked value stays in charge.
# LADOSE_API_BASE_URL=https://api.ladose.net
+23
View File
@@ -0,0 +1,23 @@
node_modules
# Output
.output
.vercel
.netlify
.wrangler
/.svelte-kit
/build
# OS
.DS_Store
Thumbs.db
# Env
.env
.env.*
!.env.example
!.env.test
# Vite
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
+1
View File
@@ -0,0 +1 @@
engine-strict=true
+1
View File
@@ -0,0 +1 @@
lts/*
+3
View File
@@ -0,0 +1,3 @@
{
"recommendations": ["svelte.svelte-vscode"]
}
+41
View File
@@ -0,0 +1,41 @@
# Build the SPA. adapter-static writes /app/build (pages == assets).
FROM node:24-alpine AS build
WORKDIR /app
# Lockfile first: this layer is reused until the dependencies actually change.
# Every build tool lives in devDependencies, so no --omit=dev here.
#
# --legacy-peer-deps works around a conflict that predates this Dockerfile:
# openapi-typescript@7.13.0 peer-requires typescript@^5.x while the project is on ^6.0.3,
# so plain `npm ci` fails ERESOLVE on every npm version tested (10.8, 10.9, 11.17).
# The flag only skips peer *validation* — the tree installed is still exactly
# package-lock.json (verified: typescript 6.0.3, lockfile unmodified).
# Drop the flag once package.json resolves that conflict.
COPY package.json package-lock.json ./
RUN npm ci --legacy-peer-deps
COPY . .
# Optional baked-in default. Precedence at runtime is
# /config.js > VITE_API_BASE_URL > the 'http://localhost:5000' in src/lib/api/client.ts.
# Declared after `npm ci` so passing it does not invalidate the dependency layer.
# client.ts uses `??`, so an empty string would beat its default: unset instead of exporting "".
ARG VITE_API_BASE_URL
# Same gate as the README's documented `npm run check`: a type regression fails the image.
RUN npm run check
RUN if [ -z "${VITE_API_BASE_URL:-}" ]; then unset VITE_API_BASE_URL; fi; npm run build
# Serve it. openapi.json / schema.d.ts are committed, so nothing here touches the live API.
FROM nginx:1.27-alpine
WORKDIR /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
COPY --from=build /app/build/ ./
EXPOSE 80
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
CMD ["nginx", "-g", "daemon off;"]
+202
View File
@@ -0,0 +1,202 @@
# LaDOSE.WebApp
Svelte 5 + SvelteKit front-end for `LaDOSE.Api`, styled with Tailwind CSS v4.
It ships as a static SPA (`@sveltejs/adapter-static`) and talks to the API over
JWT bearer auth, so it can be served from any static host.
## Requirements
Node ≥ 22.12 (the toolchain uses Vite 8). An `.nvmrc` is checked in:
```bash
nvm use # resolves lts/*
npm install
```
## Running
The API must be up — it serves on `http://localhost:5000` in development:
```bash
cd ../LaDOSE.Api && dotnet run # terminal 1
npm run dev # terminal 2 -> http://localhost:5173
```
Point the app at a different API with `VITE_API_BASE_URL` (see `.env.example`).
`LaDOSE.Api` already allows any origin with credentials, so no dev proxy is needed.
## Typed API access
`src/lib/api/schema.d.ts` is **generated** from the API's OpenAPI document — never
edit it by hand. Regenerate whenever a C# controller or DTO changes:
```bash
npm run api:sync # fetch openapi.json from the running API, then re-emit types
```
That is `api:fetch` (curl `/openapi/v1.json`, override the host with `LADOSE_API_URL`)
followed by `api:types` (`openapi-typescript`). `openapi.json` is committed so the
types can be rebuilt without a running API.
Because paths and DTOs come from the generated types, a renamed route or a changed
DTO field surfaces as a TypeScript error rather than a runtime 404.
| Module | Purpose |
| --- | --- |
| `src/lib/api/schema.d.ts` | Generated types — all 23 API paths and every DTO |
| `src/lib/api/schema-helpers.ts` | Friendly aliases (`ApplicationUserDTO`, `LoginRequest`, …) |
| `src/lib/api/client.ts` | `apiRequest` — bearer auth, JSON, `ApiError`; paths constrained to real routes |
| `src/lib/api/users.ts` | `login` / `register` against `/Users/auth` and `/Users/register` |
| `src/lib/api/errors.ts` | `toErrorMessage` — message to show, or redirect to `/login` on a 401 |
| `src/lib/api/tournaments.ts` | `listEvents` / `importSmashTournament` / `getResults`, authenticated from the session |
| `src/lib/api/games.ts` | `listGames` / `saveGame` / `deleteGame` / `searchSmashGames` |
| `src/lib/api/admin-users.ts` | `listUsers` / `listRoles` / `addUser` / `deleteUser` — all Admin-only |
| `src/lib/api/statistics.ts` | `getMatchStats` against `POST /api/Statistics/Matches` — set-level win/loss and head to head |
| `src/lib/tournaments/results.ts` | Pure reshaping of `TournamentsResultDTO`: ranking grid, per-game placements, WordPress HTML, CSV |
| `src/lib/statistics/aggregate.ts` | Pure aggregation for `/statistiques`: standings, per-game and per-event summaries, CSV |
| `src/lib/statistics/load.ts` | `loadEventResults` — per-event `GetResults` fan-out with progress, partial failure and abort |
| `src/lib/games/draft.ts` | `GameDTO` ⇄ editor form, including the blank-to-NULL rules |
| `src/lib/ui/classes.ts` | The Tailwind class strings shared by the pages |
| `src/lib/stores/session.svelte.ts` | Signed-in user, persisted to `localStorage`, drops expired JWTs |
Calling another endpoint takes one line, and the path is checked at compile time:
```ts
import { apiRequest, buildPath, session } from '$lib';
import type { GameDTO } from '$lib';
const games = await apiRequest<GameDTO[]>('/api/Game', { token: session.token });
const one = await apiRequest<GameDTO>(buildPath('/api/Game/{id}', { id: 3 }));
```
## Routes
- `/login` — username + password, posts to `POST /Users/auth`, stores the returned JWT
- `/` — guarded; greets the signed-in user with **Hello, \<name\>.** and offers sign-out
- `/tournaments` — guarded; the start.gg half of the old Avalonia `TournamentResultView`
- `/statistiques` — guarded; standings, attendance and match statistics over a chosen scope
- `/games` — guarded; the game catalogue editor, from the Avalonia `GamesView`
- `/users`**Admin only**; add and remove accounts
`session.displayName` prefers `firstName lastName` and falls back to `username`.
### `/tournaments`
Ports the Smash.gg (start.gg) column of `LaDOSE.DesktopApp.Avalonia`:
1. **Import** — a slug (`start.gg/tournament/<slug>`) is sent to
`GET /api/Tournament/ParseSmash/{slug}`, which pulls the brackets, placements and
sets into the database. Every bracket must be `COMPLETED` or the API throws.
2. **Events**`GET /api/Event`, newest first. Tick one event for a single
tournament, or several to aggregate a ranking season. The regex box replaces the
selection with every matching event name (e.g. `Ranking #13\d{2}`).
3. **Generate results**`POST /api/Tournament/GetResults` with the selected ids;
the API applies the point rules in `ExternalProviderService`. Three views:
- *Ranking* — players × games with totals, highest first, plus CSV export
- *By game* — placements and points for one game
- *HTML* — the podium table for the WordPress recap, with copy-to-clipboard
The Challonge half of the Avalonia view (date range, Challonge tournament list,
`ParseChallonge`) is deliberately not ported.
### `/statistiques`
Pick a scope — everything, the last 6/12 events, or a regex over event names — then
**Compute statistics**. Two independent sources feed the page, and they are kept apart
on purpose:
- **Points, placements, attendance** come from `POST /api/Tournament/GetResults`, called
**one event at a time**. The endpoint merges everything it is given and never says
which event a row came from, so per-event calls are the only way to get a time series
— and they contain the damage, because it throws a 500 on any bracket missing a rank-1
or rank-2 row. One broken import is reported as a skipped event instead of taking the
whole scope down with it.
- **Set-level win rates and head to head** come from `POST /api/Statistics/Matches`, in
a single call for the whole scope.
The **Matches** tab leads with its coverage line, and it matters: brackets imported
before set rows were persisted contribute placements but no matches, so those figures
can describe a fraction of the scope while the standings above cover all of it. Win
rates count **decided sets only**, so a player with no resolvable set is left out rather
than shown at 0%.
Other things worth knowing:
- Rank `999` is the service's "unplaced" sentinel (the participation bucket in
`ExternalProviderService`), so it never counts as a placement or a podium, and
**Best** shows `—`.
- Player names are merged case-insensitively, as everywhere else in the app.
- Brackets are counted per event, so a bracket name reused every month counts once
per event rather than once overall.
- Undated events sort last in the chart and the Events tab — `GET /api/Event` returns
newest first, which is the least misleading place to put them.
- `aggregate.ts` is pure, so it can be exercised under plain Node with fixtures, the
same way `src/lib/tournaments/results.ts` is.
### `/games`
Ports `GamesView`: the list on the left (ordered by `Order`), an editor on the right.
- **Save**`POST /api/Game`. `AddOrUpdate` inserts when `id` is 0 and otherwise
**replaces every column**, so the form always sends a complete `GameDTO`; blank
text fields are sent as `null`.
- **New game** — starts an empty draft with `id` 0 and the next free `Order`. The
desktop app instead posted a blank row immediately and let you fill it in after.
- **Delete**`DELETE /api/Game/{id}`, behind a confirm.
- **Find on start.gg**`GET /api/Game/smash/{name}` searches start.gg's videogame
catalogue using the long name (falling back to the name); picking a match fills
`smashId`. The ids listed are **start.gg** videogame ids, not LaDOSE game ids.
`smashId` is what bracket imports match on: a game without one collects its results
under a synthetic "GAME NOT FOUND" entry.
Unlike the desktop form, `imgUrl` is editable here — it is part of `GameDTO` and was
otherwise only reachable through the database.
### `/users` (Admin only)
Lists every account with its roles, creates accounts, and deletes them.
`POST /Users/register` used to be `[AllowAnonymous]` so that the first account could be
created. It is now `POST /Users/AddUser` and requires the **Admin** role, so before this
page is reachable at all, one account has to be promoted directly in the database:
```bash
# edit the username in the file first, then:
psql "$LADOSE_DB" -v ON_ERROR_STOP=1 -f ../../Sql/2026-08-05_roles.sql
```
How roles work:
- They live in the pre-existing `applicationrole` / `applicationuserrole` tables. The
script seeds `Admin` and `User`; no schema change was needed.
- Only user management checks a role. **Every other endpoint is unchanged** — a plain
or role-less account can still use tournaments, games and the rest.
- The JWT carries only the user id. Roles are read from the database on every request
(`OnTokenValidated` in `Startup.cs`), so granting or revoking Admin applies to the
caller's next request instead of whenever their 16-minute token expires.
- `session.isAdmin` hides the link and the page, but that is cosmetic — the API is
what enforces access, and a non-admin calling these endpoints gets a 403.
- The API refuses to delete the caller's own account. Since only an admin can reach the
endpoint, that is what guarantees at least one admin always remains.
## Notes
- The API issues 16-minute tokens. A lapsed token is treated as signed out on load;
there is no refresh flow yet, so long sessions will need a re-login. The guarded
pages turn a 401 into a redirect back to `/login` (see `toErrorMessage`).
- The API has no exception middleware, so an unhandled server error arrives as an
HTML developer page. `ApiError` then carries only the status, which is why each
call site supplies its own fallback message.
- `GetResults` only fills `slug` when **one** event id is requested, so the
"Voir le Bracket" links appear only for a single-event export.
- Player names are merged case-insensitively across brackets, matching the desktop app.
- `src/routes/+layout.ts` sets `ssr = false`: the JWT lives in the browser, so there
is nothing meaningful to render on the server.
## Checks
```bash
npm run check # svelte-check (types + template diagnostics)
npm run build # static build into ./build
```
@@ -0,0 +1,24 @@
#!/bin/sh
# Vite inlines import.meta.env.VITE_* at build time, so runtime configuration cannot
# come from an env var the app reads directly. Instead we write /config.js here, which
# src/app.html loads synchronously ahead of the bundle. One image, any environment.
set -eu
config_file=/usr/share/nginx/html/config.js
# Drop control characters (newlines included) so the value cannot break out of the
# string literal, then escape backslashes before double quotes. A URL containing
# & ? " or a trailing slash stays inert data.
value=$(printf '%s' "${LADOSE_API_BASE_URL:-}" | tr -d '\001-\037')
if [ -z "$value" ]; then
# Empty object, not an empty string: lets the app keep its baked-in default.
printf 'window.__LADOSE_CONFIG__ = {};\n' >"$config_file"
echo "config.js: LADOSE_API_BASE_URL unset, deferring to the built-in default" >&2
else
escaped=$(printf '%s' "$value" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g')
printf 'window.__LADOSE_CONFIG__ = { apiBaseUrl: "%s" };\n' "$escaped" >"$config_file"
echo "config.js: apiBaseUrl=$value" >&2
fi
exec "$@"
+58
View File
@@ -0,0 +1,58 @@
# Copied to /etc/nginx/conf.d/default.conf, replacing the image's default server.
server {
listen 80;
listen [::]:80;
server_name _;
root /usr/share/nginx/html;
index index.html;
server_tokens off;
# The build emits no .gz/.br, so compress on the fly.
gzip on;
gzip_vary on;
gzip_min_length 256;
gzip_types
application/javascript
application/json
application/manifest+json
application/wasm
image/svg+xml
text/css
text/javascript
text/plain
text/xml;
# Rewritten by docker-entrypoint.sh on every container start. Exact match, so the
# _app/immutable rule below can never apply to it.
location = /config.js {
add_header Cache-Control "no-store" always;
try_files $uri =404;
}
# Content-hashed filenames: safe forever.
location /_app/immutable/ {
add_header Cache-Control "public, max-age=31536000, immutable" always;
try_files $uri =404;
}
# Deploy marker the client polls. Stale copies mean updates are never noticed.
location = /_app/version.json {
add_header Cache-Control "no-cache" always;
try_files $uri =404;
}
# The shell. Same reason as version.json: it names the current immutable bundle.
location = /index.html {
add_header Cache-Control "no-cache" always;
}
# Nothing is prerendered (+layout.ts sets ssr = false, prerender = false) and the
# adapter's fallback is index.html, so every unknown path belongs to the client
# router. Without this, /login, /games, /tournaments, /users and /statistiques 404
# on deep-link or hard refresh. The internal redirect re-enters `location =
# /index.html`, so fallback responses pick up no-cache too.
location / {
try_files $uri $uri/ /index.html;
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+30
View File
@@ -0,0 +1,30 @@
{
"name": "ladose.webapp",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"api:fetch": "curl -fsS ${LADOSE_API_URL:-http://localhost:5000}/openapi/v1.json -o openapi.json",
"api:types": "openapi-typescript openapi.json -o src/lib/api/schema.d.ts",
"api:sync": "npm run api:fetch && npm run api:types"
},
"devDependencies": {
"@sveltejs/adapter-auto": "^7.0.1",
"@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.63.0",
"@sveltejs/vite-plugin-svelte": "^7.1.2",
"@tailwindcss/vite": "^4.3.3",
"openapi-typescript": "^7.13.0",
"svelte": "^5.56.1",
"svelte-check": "^4.6.0",
"tailwindcss": "^4.3.3",
"typescript": "^6.0.3",
"vite": "^8.0.16"
}
}
+218
View File
@@ -0,0 +1,218 @@
@import 'tailwindcss';
/*
* Colour is expressed twice over:
*
* 1. the LaDOSE brand ramp below fixed values, theme-independent;
* 2. semantic tokens (canvas, surface, ink, accent, ) that every component
* actually uses, and that swap wholesale between light and dark.
*
* Components must only ever reach for the semantic layer. Adding `dark:` next to
* a hundred hard-coded shades would double every future edit; one set of tokens
* that changes underneath them does not.
*/
@theme {
--color-ladose-50: #f2f6ff;
--color-ladose-100: #e4ebff;
--color-ladose-200: #c3d4ff;
--color-ladose-300: #9db4ff;
--color-ladose-400: #6f8fff;
--color-ladose-500: #4b6cff;
--color-ladose-600: #3450e6;
--color-ladose-700: #2a3fb8;
--color-ladose-800: #1e2d80;
--color-ladose-900: #131a3d;
--color-ladose-950: #0b1027;
}
/*
* `inline` is load-bearing. Without it Tailwind emits `--color-surface:
* var(--app-surface)` into :root and the utility resolves through that copy, so
* redefining --app-surface in a more specific selector does nothing. `inline`
* substitutes the reference straight into the utility, which is what makes
* runtime theme switching work at all in Tailwind v4.
*/
@theme inline {
--color-canvas: var(--app-canvas);
--color-surface: var(--app-surface);
--color-overlay: var(--app-overlay);
--color-inset: var(--app-inset);
--color-line: var(--app-line);
--color-line-strong: var(--app-line-strong);
--color-ink: var(--app-ink);
--color-muted: var(--app-muted);
--color-subtle: var(--app-subtle);
--color-accent: var(--app-accent);
--color-accent-hover: var(--app-accent-hover);
--color-on-accent: var(--app-on-accent);
--color-danger: var(--app-danger);
--color-danger-soft: var(--app-danger-soft);
--color-success: var(--app-success);
--color-success-soft: var(--app-success-soft);
--color-warning: var(--app-warning);
--color-warning-soft: var(--app-warning-soft);
--shadow-card: var(--app-shadow);
}
/*
* Palette values live here once each. The three blocks that follow only
* re-point the --app-* aliases, so a colour is never written twice.
*/
:root {
--light-canvas: #f6f7fb;
--light-surface: #ffffff;
--light-overlay: #ffffff;
--light-inset: #f1f3f9;
--light-line: #dfe3ee;
--light-line-strong: #c6cddf;
--light-ink: #101430;
--light-muted: #4c5470;
--light-subtle: #767e99;
--light-accent: var(--color-ladose-600);
--light-accent-hover: var(--color-ladose-700);
--light-on-accent: #ffffff;
--light-danger: #b42318;
--light-danger-soft: rgb(180 35 24 / 0.09);
--light-success: #067647;
--light-success-soft: rgb(6 118 71 / 0.09);
--light-warning: #b54708;
--light-warning-soft: rgb(181 71 8 / 0.09);
--light-shadow: 0 1px 2px rgb(16 24 40 / 0.06), 0 10px 28px rgb(16 24 40 / 0.08);
--light-grad-from: #ffffff;
--light-grad-to: #eaeef8;
/* Translucent surfaces on purpose: they sit over the canvas gradient and
keep the depth the app had before tokens existed. */
--dark-canvas: var(--color-ladose-950);
--dark-surface: rgb(255 255 255 / 0.05);
/* Menus and popovers sit over content, so they need to be opaque. */
--dark-overlay: #171e3d;
--dark-inset: rgb(11 16 39 / 0.6);
--dark-line: rgb(255 255 255 / 0.1);
--dark-line-strong: rgb(255 255 255 / 0.16);
--dark-ink: var(--color-ladose-50);
--dark-muted: rgb(195 212 255 / 0.7);
--dark-subtle: rgb(195 212 255 / 0.45);
--dark-accent: var(--color-ladose-500);
--dark-accent-hover: var(--color-ladose-600);
--dark-on-accent: #ffffff;
--dark-danger: #fca5a5;
--dark-danger-soft: rgb(239 68 68 / 0.12);
--dark-success: #6ee7b7;
--dark-success-soft: rgb(16 185 129 / 0.12);
--dark-warning: #fcd34d;
--dark-warning-soft: rgb(245 158 11 / 0.12);
--dark-shadow: 0 1px 2px rgb(0 0 0 / 0.3), 0 16px 40px rgb(0 0 0 / 0.35);
--dark-grad-from: var(--color-ladose-900);
--dark-grad-to: var(--color-ladose-950);
}
/* Layer 1 — light is the unconditional default. */
:root {
--app-color-scheme: light;
--app-canvas: var(--light-canvas);
--app-surface: var(--light-surface);
--app-overlay: var(--light-overlay);
--app-inset: var(--light-inset);
--app-line: var(--light-line);
--app-line-strong: var(--light-line-strong);
--app-ink: var(--light-ink);
--app-muted: var(--light-muted);
--app-subtle: var(--light-subtle);
--app-accent: var(--light-accent);
--app-accent-hover: var(--light-accent-hover);
--app-on-accent: var(--light-on-accent);
--app-danger: var(--light-danger);
--app-danger-soft: var(--light-danger-soft);
--app-success: var(--light-success);
--app-success-soft: var(--light-success-soft);
--app-warning: var(--light-warning);
--app-warning-soft: var(--light-warning-soft);
--app-shadow: var(--light-shadow);
--app-grad-from: var(--light-grad-from);
--app-grad-to: var(--light-grad-to);
}
/*
* Layer 2 follow the OS to dark, unless the user explicitly chose light.
* The :not() is what lets an explicit light choice win on a dark machine: a bare
* :root in layer 1 could never outrank a later media block on its own.
*/
@media (prefers-color-scheme: dark) {
:root:not([data-theme='light']) {
--app-color-scheme: dark;
--app-canvas: var(--dark-canvas);
--app-surface: var(--dark-surface);
--app-overlay: var(--dark-overlay);
--app-inset: var(--dark-inset);
--app-line: var(--dark-line);
--app-line-strong: var(--dark-line-strong);
--app-ink: var(--dark-ink);
--app-muted: var(--dark-muted);
--app-subtle: var(--dark-subtle);
--app-accent: var(--dark-accent);
--app-accent-hover: var(--dark-accent-hover);
--app-on-accent: var(--dark-on-accent);
--app-danger: var(--dark-danger);
--app-danger-soft: var(--dark-danger-soft);
--app-success: var(--dark-success);
--app-success-soft: var(--dark-success-soft);
--app-warning: var(--dark-warning);
--app-warning-soft: var(--dark-warning-soft);
--app-shadow: var(--dark-shadow);
--app-grad-from: var(--dark-grad-from);
--app-grad-to: var(--dark-grad-to);
}
}
/* Layer 3 — an explicit dark choice beats an OS light preference. */
:root[data-theme='dark'] {
--app-color-scheme: dark;
--app-canvas: var(--dark-canvas);
--app-surface: var(--dark-surface);
--app-overlay: var(--dark-overlay);
--app-inset: var(--dark-inset);
--app-line: var(--dark-line);
--app-line-strong: var(--dark-line-strong);
--app-ink: var(--dark-ink);
--app-muted: var(--dark-muted);
--app-subtle: var(--dark-subtle);
--app-accent: var(--dark-accent);
--app-accent-hover: var(--dark-accent-hover);
--app-on-accent: var(--dark-on-accent);
--app-danger: var(--dark-danger);
--app-danger-soft: var(--dark-danger-soft);
--app-success: var(--dark-success);
--app-success-soft: var(--dark-success-soft);
--app-warning: var(--dark-warning);
--app-warning-soft: var(--dark-warning-soft);
--app-shadow: var(--dark-shadow);
--app-grad-from: var(--dark-grad-from);
--app-grad-to: var(--dark-grad-to);
}
/*
* The canvas has to be painted by CSS on html/body, not by a class on a Svelte
* element: ssr is off, so that element does not exist until the bundle hydrates.
* Anything that relies on hydration to paint the background flashes white on
* every cold load of every route.
*/
html {
color-scheme: var(--app-color-scheme);
background-color: var(--app-canvas);
}
body {
min-height: 100svh;
background-image: radial-gradient(ellipse at top, var(--app-grad-from), var(--app-grad-to));
background-repeat: no-repeat;
background-attachment: fixed;
color: var(--app-ink);
}
/* Height of the fixed navbar, so pages that centre themselves can subtract it. */
:root {
--nav-h: 3.5rem;
}
+13
View File
@@ -0,0 +1,13 @@
// See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces
declare global {
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
}
export {};
+37
View File
@@ -0,0 +1,37 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="text-scale" content="scale" />
<!--
Runtime configuration, written by the container entrypoint from
LADOSE_API_BASE_URL. Synchronous and ahead of the app bundle so
src/lib/api/client.ts can read it at module scope. Absent in `vite dev`,
which is why the client falls back to VITE_API_BASE_URL.
-->
<script src="%sveltekit.assets%/config.js"></script>
<!--
Theme, before %sveltekit.head% emits the render-blocking stylesheet, so
data-theme is already on <html> when its selectors are first matched.
Must stay a plain synchronous inline script: type=module, defer, async or
an external file all run after first paint, which is the flash we are
avoiding. Only ever writes 'light' or 'dark' — the CSS keys off those two
exact values, so a literal data-theme="system" would match neither.
-->
<script>
try {
var t = localStorage.getItem('ladose.theme');
if (t === 'light' || t === 'dark') document.documentElement.dataset.theme = t;
} catch (e) {
/* localStorage throws in Safari private mode and with cookies blocked */
}
</script>
<!--
%sveltekit.head%
-->
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>
@@ -0,0 +1,53 @@
import { session } from '$lib/stores/session.svelte';
import { apiRequest, buildPath, type RequestOptions } from './client';
import type { ApplicationUserDTO, NewUserRequest } from './schema-helpers';
/**
* User administration. Every endpoint here is `[Authorize(Roles = "Admin")]`, so a
* non-admin gets a 403 the UI hides the page, but the API is what enforces it.
*
* `login` and the session live in `users.ts`; this module is only the admin screen.
*/
function authed(options: RequestOptions): RequestOptions {
return { ...options, token: options.token ?? session.token };
}
/** GET /Users — every account, ordered by username. Never includes password material. */
export function listUsers(options: RequestOptions = {}): Promise<ApplicationUserDTO[]> {
return apiRequest<ApplicationUserDTO[]>('/Users', authed(options));
}
/**
* GET /Users/Roles the role names that exist in `applicationrole`. Assigning a name
* that is not in this list is rejected by the API rather than creating a new role.
*/
export function listRoles(options: RequestOptions = {}): Promise<string[]> {
return apiRequest<string[]>('/Users/Roles', authed(options));
}
/**
* POST /Users/AddUser replaces the old anonymous `register`. Returns the created
* user. A duplicate username or an unknown role name comes back as a 400 carrying
* the reason in `message`.
*/
export function addUser(
user: NewUserRequest,
options: RequestOptions = {}
): Promise<ApplicationUserDTO> {
return apiRequest<ApplicationUserDTO>('/Users/AddUser', {
...authed(options),
method: 'POST',
body: user
});
}
/**
* DELETE /Users/{id} 204 on success. The API refuses to delete the caller's own
* account (400), which is what guarantees an admin always remains.
*/
export async function deleteUser(id: number, options: RequestOptions = {}): Promise<void> {
await apiRequest<void>(buildPath('/Users/{id}', { id }), {
...authed(options),
method: 'DELETE'
});
}
@@ -0,0 +1,113 @@
import type { ApiPath } from './schema-helpers';
/** Shape of the object `static/config.js` defines, rewritten at container start. */
declare global {
interface Window {
__LADOSE_CONFIG__?: { apiBaseUrl?: string };
}
}
/**
* Base URL of LaDOSE.Api, resolved in this order:
*
* 1. `window.__LADOSE_CONFIG__.apiBaseUrl` written by the container entrypoint
* from `LADOSE_API_BASE_URL` (see Dockerfile). This is what lets one image
* serve several environments: Vite inlines `import.meta.env` at *build* time,
* so without it the URL would be frozen into the bundle.
* 2. `VITE_API_BASE_URL` baked at build time, for `vite dev` and for anyone
* who prefers one image per environment.
* 3. the Kestrel binding the API uses in development (LaDOSE.Api/appsettings.json).
*/
function resolveBaseUrl(): string {
// `typeof window` rather than a browser guard: this runs at module scope, and
// the static build still evaluates this module while generating the shell.
const runtime =
typeof window !== 'undefined' ? window.__LADOSE_CONFIG__?.apiBaseUrl : undefined;
const configured = runtime?.trim() || import.meta.env.VITE_API_BASE_URL || 'http://localhost:5000';
return configured.replace(/\/$/, '');
}
export const API_BASE_URL = resolveBaseUrl();
/** Thrown for any non-2xx response, carrying the API's message when it sent one. */
export class ApiError extends Error {
constructor(
readonly status: number,
message: string
) {
super(message);
this.name = 'ApiError';
}
}
/** The shape LaDOSE.Api uses for its error payloads: `new { message = "..." }`. */
function extractMessage(body: unknown, status: number): string {
if (body && typeof body === 'object' && 'message' in body) {
const { message } = body as { message?: unknown };
if (typeof message === 'string' && message.length > 0) return message;
}
return `Request failed with status ${status}`;
}
export interface RequestOptions {
method?: 'GET' | 'POST' | 'DELETE';
body?: unknown;
/** JWT from `POST /Users/auth`; sent as `Authorization: Bearer ...`. */
token?: string | null;
fetch?: typeof globalThis.fetch;
signal?: AbortSignal;
}
/**
* Calls LaDOSE.Api. `path` is constrained to the paths in the generated OpenAPI
* types, so a typo or a route removed on the server is a compile error.
* Templated paths (e.g. `/api/Game/{id}`) are built with `buildPath`.
*/
export async function apiRequest<TResponse>(
path: ApiPath | (string & {}),
options: RequestOptions = {}
): Promise<TResponse> {
const { method = 'GET', body, token, fetch: fetchImpl = globalThis.fetch, signal } = options;
const headers: Record<string, string> = { Accept: 'application/json' };
if (body !== undefined) headers['Content-Type'] = 'application/json';
if (token) headers['Authorization'] = `Bearer ${token}`;
let response: Response;
try {
response = await fetchImpl(`${API_BASE_URL}${path}`, {
method,
headers,
body: body === undefined ? undefined : JSON.stringify(body),
signal
});
} catch (cause) {
if (cause instanceof DOMException && cause.name === 'AbortError') throw cause;
// fetch only rejects on transport failures — the API being down, DNS, CORS.
const error = new ApiError(0, `Could not reach LaDOSE.Api at ${API_BASE_URL}`);
error.cause = cause;
throw error;
}
const isJson = response.headers.get('content-type')?.includes('json') ?? false;
const payload = isJson ? await response.json().catch(() => null) : null;
if (!response.ok) {
throw new ApiError(response.status, extractMessage(payload, response.status));
}
return payload as TResponse;
}
/** Fills a templated OpenAPI path, e.g. buildPath('/api/Game/{id}', { id: 3 }). */
export function buildPath(
template: ApiPath,
params: Record<string, string | number>
): string {
return template.replace(/\{(\w+)\}/g, (_, key: string) => {
const value = params[key];
if (value === undefined) throw new Error(`Missing route parameter "${key}" for ${template}`);
return encodeURIComponent(String(value));
});
}
@@ -0,0 +1,21 @@
import { goto } from '$app/navigation';
import { session } from '$lib/stores/session.svelte';
import { ApiError } from './client';
/**
* Turns a failed call into something to show the user.
*
* A 401 means the 16-minute JWT lapsed mid-session: there is nothing useful to
* display, so the session is dropped and the user is sent back to `/login`, and
* this returns null.
*/
export function toErrorMessage(cause: unknown, fallback: string): string | null {
if (cause instanceof ApiError && cause.status === 401) {
session.clear();
void goto('/login', { replaceState: true });
return null;
}
// ApiError already carries the API's own `message`, or "unreachable" for status 0.
return cause instanceof ApiError ? cause.message : fallback;
}
@@ -0,0 +1,45 @@
import { session } from '$lib/stores/session.svelte';
import { apiRequest, buildPath, type RequestOptions } from './client';
import type { GameDTO } from './schema-helpers';
/** GameController is `[Authorize]`; reuse the session JWT unless one is passed in. */
function authed(options: RequestOptions): RequestOptions {
return { ...options, token: options.token ?? session.token };
}
/** GET /api/Game — every game, in database order (sort by `order` for display). */
export function listGames(options: RequestOptions = {}): Promise<GameDTO[]> {
return apiRequest<GameDTO[]>('/api/Game', authed(options));
}
/**
* POST /api/Game `AddOrUpdate`: an `id` of 0 inserts, anything else updates.
* The update replaces every column, so send a full DTO rather than a patch.
* Returns the saved game with its assigned id.
*/
export function saveGame(game: GameDTO, options: RequestOptions = {}): Promise<GameDTO> {
return apiRequest<GameDTO>('/api/Game', { ...authed(options), method: 'POST', body: game });
}
/**
* DELETE /api/Game/{id} 204 on success. The service swallows `DbUpdateException`,
* so a game still referenced by tournaments comes back as a 404 rather than a 409.
*/
export async function deleteGame(id: number, options: RequestOptions = {}): Promise<void> {
await apiRequest<void>(buildPath('/api/Game/{id}', { id }), {
...authed(options),
method: 'DELETE'
});
}
/**
* GET /api/Game/smash/{name} searches start.gg's videogame catalogue by name.
* The `id` of each match is a **start.gg** videogame id, i.e. a candidate value for
* `GameDTO.smashId`, not a LaDOSE game id.
*/
export function searchSmashGames(
name: string,
options: RequestOptions = {}
): Promise<GameDTO[]> {
return apiRequest<GameDTO[]>(buildPath('/api/Game/smash/{name}', { name }), authed(options));
}

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