Recreated solution with web app template. Improved theme. Switched to ShadcnBlazor library

This commit is contained in:
2025-12-25 19:16:53 +01:00
parent 0cc35300f1
commit a2d4edc0e5
272 changed files with 2441 additions and 14449 deletions

View File

@@ -0,0 +1,28 @@
using System.Text.Json;
using Moonlight.Shared.Http;
namespace Moonlight.Frontend;
public static class Constants
{
public static JsonSerializerOptions SerializerOptions
{
get
{
if (InternalOptions != null)
return InternalOptions;
InternalOptions = new()
{
PropertyNameCaseInsensitive = true
};
// Add source generated options from shared project
InternalOptions.TypeInfoResolverChain.Add(SerializationContext.Default);
return InternalOptions;
}
}
private static JsonSerializerOptions? InternalOptions;
}

View File

@@ -0,0 +1,14 @@
using System.Diagnostics.CodeAnalysis;
using Riok.Mapperly.Abstractions;
using Moonlight.Shared.Http.Requests.Users;
using Moonlight.Shared.Http.Responses.Users;
namespace Moonlight.Frontend.Mappers;
[Mapper]
[SuppressMessage("Mapper", "RMG020:No members are mapped in an object mapping")]
[SuppressMessage("Mapper", "RMG012:No members are mapped in an object mapping")]
public static partial class UserMapper
{
public static partial UpdateUserRequest MapToUpdate(UserResponse response);
}

View File

@@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk.Razor">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<StaticWebAssetsEnabled>True</StaticWebAssetsEnabled>
<CompressionEnabled>false</CompressionEnabled>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Components.Authorization" Version="10.0.1"/>
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="10.0.1"/>
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="10.0.1" PrivateAssets="all"/>
<PackageReference Include="Riok.Mapperly" Version="4.3.1-next.0"/>
<PackageReference Include="ShadcnBlazor" Version="1.0.4"/>
<PackageReference Include="ShadcnBlazor.Extras" Version="1.0.4"/>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Moonlight.Shared\Moonlight.Shared.csproj"/>
</ItemGroup>
</Project>

View File

@@ -0,0 +1,47 @@
using System.Net;
using System.Net.Http.Json;
using System.Security.Claims;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.Extensions.Logging;
using Moonlight.Shared.Http.Responses.Auth;
namespace Moonlight.Frontend.Services;
public class RemoteAuthProvider : AuthenticationStateProvider
{
private readonly ILogger<RemoteAuthProvider> Logger;
private readonly HttpClient HttpClient;
public RemoteAuthProvider(ILogger<RemoteAuthProvider> logger, HttpClient httpClient)
{
Logger = logger;
HttpClient = httpClient;
}
public override async Task<AuthenticationState> GetAuthenticationStateAsync()
{
try
{
var claimResponses = await HttpClient.GetFromJsonAsync<ClaimResponse[]>(
"api/auth/claims", Constants.SerializerOptions
);
var claims = claimResponses!.Select(claim => new Claim(claim.Type, claim.Value));
return new AuthenticationState(
new ClaimsPrincipal(new ClaimsIdentity(claims, "remote"))
);
}
catch (HttpRequestException e)
{
if (e.StatusCode != HttpStatusCode.Unauthorized)
Logger.LogError(e, "An api error occured while requesting claims from api");
}
catch (Exception e)
{
Logger.LogError(e, "An unhandled error occured while requesting claims from api");
}
return new AuthenticationState(new ClaimsPrincipal());
}
}

View File

@@ -0,0 +1,9 @@
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
namespace Moonlight.Frontend.Startup;
public interface IAppStartup
{
public void PreBuild(WebAssemblyHostBuilder builder);
public void PostBuild(WebAssemblyHost application);
}

View File

@@ -0,0 +1,16 @@
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
using Microsoft.Extensions.DependencyInjection;
using Moonlight.Frontend.Services;
namespace Moonlight.Frontend.Startup;
public partial class Startup
{
public void AddAuth(WebAssemblyHostBuilder builder)
{
builder.Services.AddScoped<AuthenticationStateProvider, RemoteAuthProvider>();
builder.Services.AddAuthorizationCore();
builder.Services.AddCascadingAuthenticationState();
}
}

View File

@@ -0,0 +1,22 @@
using Microsoft.AspNetCore.Components.Web;
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
using Microsoft.Extensions.DependencyInjection;
using Moonlight.Frontend.UI;
using ShadcnBlazor;
using ShadcnBlazor.Extras;
namespace Moonlight.Frontend.Startup;
public partial class Startup
{
public void AddBase(WebAssemblyHostBuilder builder)
{
builder.RootComponents.Add<App>("#app");
builder.RootComponents.Add<HeadOutlet>("head::after");
builder.Services.AddScoped(_ => new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) });
builder.Services.AddShadcnBlazor();
builder.Services.AddShadcnBlazorExtras();
}
}

View File

@@ -0,0 +1,17 @@
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
namespace Moonlight.Frontend.Startup;
public partial class Startup : IAppStartup
{
public void PreBuild(WebAssemblyHostBuilder builder)
{
AddBase(builder);
AddAuth(builder);
}
public void PostBuild(WebAssemblyHost application)
{
}
}

View File

@@ -0,0 +1,54 @@
@using LucideBlazor
@using Microsoft.AspNetCore.Components.Authorization
@using ShadcnBlazor.Emptys
@using Moonlight.Frontend.UI.Components.Auth
@using Moonlight.Frontend.UI.Partials
@using Moonlight.Frontend.UI.Views
<ErrorBoundary>
<ChildContent>
<AuthorizeView>
<ChildContent>
<Router AppAssembly="@typeof(App).Assembly" NotFoundPage="typeof(NotFound)">
<Found Context="routeData">
<AuthorizeRouteView RouteData="routeData" DefaultLayout="typeof(MainLayout)">
<NotAuthorized Context="authRouteViewContext">
<AccessDenied/>
</NotAuthorized>
</AuthorizeRouteView>
</Found>
</Router>
</ChildContent>
<Authorizing>
<Authenticating/>
</Authorizing>
<NotAuthorized>
@if (context.User.Identity?.IsAuthenticated ?? false)
{
<AccessDenied/>
}
else
{
<Authentication/>
}
</NotAuthorized>
</AuthorizeView>
</ChildContent>
<ErrorContent>
<div class="m-10">
<Empty>
<EmptyHeader>
<EmptyMedia Variant="EmptyMediaVariant.Icon">
<OctagonAlertIcon ClassName="text-red-500/80"/>
</EmptyMedia>
<EmptyTitle>
Critical Application Error
</EmptyTitle>
<EmptyDescription>
@context.ToString()
</EmptyDescription>
</EmptyHeader>
</Empty>
</div>
</ErrorContent>
</ErrorBoundary>

View File

@@ -0,0 +1,18 @@
@using LucideBlazor
@using ShadcnBlazor.Emptys
<div class="m-10 flex items-center justify-center">
<Empty>
<EmptyHeader>
<EmptyMedia Variant="EmptyMediaVariant.Icon">
<BanIcon/>
</EmptyMedia>
<EmptyTitle>
Permission denied
</EmptyTitle>
<EmptyDescription>
You are not allowed to access this resource
</EmptyDescription>
</EmptyHeader>
</Empty>
</div>

View File

@@ -0,0 +1,15 @@
@using LucideBlazor
@using ShadcnBlazor.Emptys
<div class="h-screen w-full flex items-center justify-center">
<Empty>
<EmptyHeader>
<EmptyMedia Variant="EmptyMediaVariant.Icon">
<ScanFaceIcon/>
</EmptyMedia>
<EmptyTitle>
Authenticating
</EmptyTitle>
</EmptyHeader>
</Empty>
</div>

View File

@@ -0,0 +1,62 @@
@using ShadcnBlazor.Cards
@using ShadcnBlazor.Spinners
@using ShadcnBlazor.Buttons
@using Moonlight.Shared.Http.Responses.Auth
@inject HttpClient HttpClient
@inject NavigationManager Navigation
<div class="h-screen w-full flex items-center justify-center">
<Card ClassName="w-full max-w-sm">
@if (Schemes == null || Schemes.Length == 1)
{
<div class="w-full flex justify-center items-center">
<Spinner ClassName="size-10"/>
</div>
}
else
{
<CardHeader>
<CardTitle>Login to WebApp</CardTitle>
<CardDescription>Select a login provider to continue</CardDescription>
</CardHeader>
<CardContent>
<div class="flex flex-col gap-y-1.5">
@foreach (var scheme in Schemes)
{
<Button>
<Slot>
<a href="/api/auth/@scheme.Name" @attributes="context">
Continue with @scheme.DisplayName
</a>
</Slot>
</Button>
}
</div>
</CardContent>
}
</Card>
</div>
@code
{
private SchemeResponse[]? Schemes;
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (!firstRender)
return;
var schemes = await HttpClient.GetFromJsonAsync<SchemeResponse[]>(
"api/auth", Constants.SerializerOptions
);
if (schemes == null)
return;
Schemes = schemes;
if (schemes.Length == 1)
Navigation.NavigateTo($"/api/auth/{schemes[0].Name}", true);
}
}

View File

@@ -0,0 +1,9 @@
@using ShadcnBlazor.Sidebars
<header
class="flex h-(--header-height) shrink-0 items-center gap-2 border-b transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-(--header-height)"
style="--header-height: calc(var(--spacing) * 12)">
<div class="flex w-full items-center gap-1 px-4 lg:gap-2 lg:px-6">
<SidebarTrigger ClassName="-ml-1"/>
</div>
</header>

View File

@@ -0,0 +1,124 @@
@using System.Diagnostics.CodeAnalysis
@using LucideBlazor
@using ShadcnBlazor.Sidebars
@inject NavigationManager Navigation
@implements IDisposable
@{
var url = new Uri(Navigation.Uri);
}
<Sidebar Variant="SidebarVariant.Sidebar" Collapsible="SidebarCollapsible.Icon">
<SidebarHeader>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton>
<a href="/" class="flex flex-row items-center">
<img alt="Logo" src="/_content/Moonlight.Frontend/logo.svg" class="size-6"/>
<span class="ms-2.5 text-lg font-semibold">Moonlight</span>
</a>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarHeader>
<SidebarContent>
@foreach (var group in Items.GroupBy(x => x.Group))
{
<SidebarGroup>
@if (!string.IsNullOrWhiteSpace(group.Key))
{
<SidebarGroupLabel>
@group.Key
</SidebarGroupLabel>
}
<SidebarGroupContent>
<SidebarMenu>
@foreach (var item in group)
{
var isActive = item.IsExactPath
? item.Path == url.LocalPath
: url.LocalPath.StartsWith(item.Path, StringComparison.OrdinalIgnoreCase);
<SidebarMenuItem @key="item">
<SidebarMenuButton IsActive="@isActive">
<Slot>
<a href="@item.Path" @attributes="context">
<DynamicComponent Type="item.IconType"/>
<span>@item.Name</span>
</a>
</Slot>
</SidebarMenuButton>
</SidebarMenuItem>
}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
}
</SidebarContent>
<SidebarFooter>
<NavUser/>
</SidebarFooter>
</Sidebar>
@code
{
private readonly List<SidebarItem> Items = new();
protected override void OnInitialized()
{
Items.AddRange([
new()
{
Name = "Overview",
IconType = typeof(LayoutDashboardIcon),
Path = "/",
IsExactPath = true
},
new()
{
Name = "Users",
IconType = typeof(UsersRoundIcon),
Path = "/users",
IsExactPath = false,
Group = "Admin"
},
]);
Navigation.LocationChanged += OnLocationChanged;
}
private async void OnLocationChanged(object? sender, LocationChangedEventArgs e)
{
try
{
await InvokeAsync(StateHasChanged);
}
catch (Exception)
{
// Ignored
}
}
private record SidebarItem
{
public string Name { get; set; }
public string Path { get; set; }
public bool IsExactPath { get; set; }
public string? Group { get; set; }
// Used to prevent the IL-Trimming from removing this type as its dynamically assigned a type and we
// need it to work properly
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)]
public Type IconType { get; set; }
}
public void Dispose()
{
Navigation.LocationChanged -= OnLocationChanged;
}
}

View File

@@ -0,0 +1,25 @@
@using ShadcnBlazor.Extras.AlertDialogs
@using ShadcnBlazor.Extras.Alerts
@using ShadcnBlazor.Extras.Dialogs
@using ShadcnBlazor.Extras.Toasts
@using ShadcnBlazor.Sidebars
@inherits LayoutComponentBase
<SidebarProvider DefaultOpen="true">
<AppSidebar/>
<SidebarInset>
<AppHeader/>
<div class="mx-8 my-8 max-w-full">
<AlertLauncher/>
@Body
</div>
<ToastLauncher/>
<DialogLauncher/>
<AlertDialogLauncher/>
</SidebarInset>
</SidebarProvider>

View File

@@ -0,0 +1,76 @@
@using System.Security.Claims
@using LucideBlazor
@using Microsoft.AspNetCore.Components.Authorization
@using ShadcnBlazor.Avatars
@using ShadcnBlazor.Dropdowns
@using ShadcnBlazor.Interop
@using ShadcnBlazor.Sidebars
@inject NavigationManager Navigation
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger>
<Slot>
<SidebarMenuButton
Size="SidebarMenuButtonSize.Lg"
ClassName="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
@attributes="context">
<Avatar className="h-8 w-8 rounded-lg">
<AvatarImage src="https://github.com/ghost.png" alt="Ghost"/>
<AvatarFallback className="rounded-lg">GH</AvatarFallback>
</Avatar>
<div class="grid flex-1 text-left text-sm leading-tight">
<span class="truncate font-medium">@Username</span>
<span class="truncate text-xs">@Email</span>
</div>
<ChevronsUpDownIcon ClassName="ml-auto size-4"/>
</SidebarMenuButton>
</Slot>
</DropdownMenuTrigger>
<DropdownMenuContent
ClassName="w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg"
Side="@(SidebarProvider.IsMobile ? PositionSide.Bottom : PositionSide.Right)"
Align="PositionAlignment.End">
<DropdownMenuLabel ClassName="p-0 font-normal">
<div class="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
<Avatar ClassName="h-8 w-8 rounded-lg">
<AvatarImage src="https://github.com/ghost.png" alt="Ghost"/>
<AvatarFallback className="rounded-lg">GH</AvatarFallback>
</Avatar>
<div class="grid flex-1 text-left text-sm leading-tight">
<span class="truncate font-medium">@Username</span>
<span class="truncate text-xs">@Email</span>
</div>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator/>
<DropdownMenuItem OnClick="Logout">
<LogOutIcon/>
Log out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
@code
{
[CascadingParameter] public SidebarProvider SidebarProvider { get; set; }
[CascadingParameter] public Task<AuthenticationState> AuthState { get; set; }
private string Username;
private string Email;
protected override async Task OnInitializedAsync()
{
var authState = await AuthState;
Username = authState.User.FindFirst(ClaimTypes.Name)?.Value ?? "N/A";
Email = authState.User.FindFirst(ClaimTypes.Email)?.Value ?? "N/A";
}
private void Logout() => Navigation.NavigateTo("/api/auth/logout", true);
}

View File

@@ -0,0 +1 @@
@page "/"

View File

@@ -0,0 +1,24 @@
@page "/notfound"
@using LucideBlazor
@using ShadcnBlazor.Emptys
@using ShadcnBlazor.Buttons
<Empty>
<EmptyHeader>
<EmptyMedia Variant="EmptyMediaVariant.Icon">
<FolderOpenIcon/>
</EmptyMedia>
<EmptyTitle>Page not found</EmptyTitle>
<EmptyDescription>
The page you requested could not be found
</EmptyDescription>
</EmptyHeader>
<EmptyContent>
<Button>
<Slot>
<a href="/" @attributes="context">Go to home</a>
</Slot>
</Button>
</EmptyContent>
</Empty>

View File

@@ -0,0 +1,91 @@
@page "/users/create"
@using LucideBlazor
@using ShadcnBlazor.Buttons
@using ShadcnBlazor.Labels
@using ShadcnBlazor.Cards
@using ShadcnBlazor.Extras.FormHandlers
@using ShadcnBlazor.Extras.Toasts
@using ShadcnBlazor.Inputs
@using Moonlight.Shared.Http.Requests.Users
@inject HttpClient HttpClient
@inject NavigationManager Navigation
@inject ToastService ToastService
<div class="flex flex-row justify-between">
<div class="flex flex-col">
<h1 class="text-xl font-semibold">Create user</h1>
<div class="text-muted-foreground">
Create a new user
</div>
</div>
<div class="flex flex-row gap-x-1.5">
<Button Variant="ButtonVariant.Secondary">
<Slot>
<a href="/users" @attributes="context">
<ChevronLeftIcon/>
Back
</a>
</Slot>
</Button>
<Button @onclick="() => Form.SubmitAsync()">
<CheckIcon/>
Continue
</Button>
</div>
</div>
<div class="mt-8">
<Card>
<CardContent>
<FormHandler @ref="Form" OnValidSubmit="OnSubmitAsync" Model="Request">
<div class="flex flex-col gap-6">
<FormValidationSummary/>
<div class="grid gap-2">
<Label for="email">Email</Label>
<InputField
@bind-Value="Request.Email"
id="email"
Type="email"
placeholder="m@example.com"/>
</div>
<div class="grid gap-2">
<Label for="email">Username</Label>
<InputField
@bind-Value="Request.Username"
id="username"
Type="text"
placeholder="example_user"/>
</div>
</div>
</FormHandler>
</CardContent>
</Card>
</div>
@code
{
private CreateUserRequest Request = new();
private FormHandler Form;
private async Task OnSubmitAsync()
{
await HttpClient.PostAsJsonAsync(
"/api/users",
Request,
Constants.SerializerOptions
);
await ToastService.SuccessAsync(
"User creation",
$"Successfully created user {Request.Username}"
);
Navigation.NavigateTo("/users");
}
}

View File

@@ -0,0 +1,105 @@
@page "/users/{Id:int}"
@using LucideBlazor
@using ShadcnBlazor.Buttons
@using ShadcnBlazor.Labels
@using ShadcnBlazor.Cards
@using ShadcnBlazor.Extras.Common
@using ShadcnBlazor.Extras.FormHandlers
@using ShadcnBlazor.Extras.Toasts
@using ShadcnBlazor.Inputs
@using Moonlight.Frontend.Mappers
@using Moonlight.Shared.Http.Requests.Users
@using Moonlight.Shared.Http.Responses.Users
@inject HttpClient HttpClient
@inject NavigationManager Navigation
@inject ToastService ToastService
<div class="flex flex-row justify-between">
<div class="flex flex-col">
<h1 class="text-xl font-semibold">Update user</h1>
<div class="text-muted-foreground">
Update an existing user
</div>
</div>
<div class="flex flex-row gap-x-1.5">
<Button Variant="ButtonVariant.Secondary">
<Slot>
<a href="/users" @attributes="context">
<ChevronLeftIcon/>
Back
</a>
</Slot>
</Button>
<Button @onclick="() => Form.SubmitAsync()">
<CheckIcon/>
Continue
</Button>
</div>
</div>
<div class="mt-8">
<Card>
<CardContent>
<LazyLoader Load="LoadAsync">
<FormHandler @ref="Form" OnValidSubmit="OnSubmitAsync" Model="Request">
<div class="flex flex-col gap-6">
<FormValidationSummary/>
<div class="grid gap-2">
<Label for="email">Email</Label>
<InputField
@bind-Value="Request.Email"
id="email"
Type="email"
placeholder="m@example.com"/>
</div>
<div class="grid gap-2">
<Label for="email">Username</Label>
<InputField
@bind-Value="Request.Username"
id="username"
Type="text"
placeholder="example_user"/>
</div>
</div>
</FormHandler>
</LazyLoader>
</CardContent>
</Card>
</div>
@code
{
[Parameter] public int Id { get; set; }
private FormHandler Form;
private UpdateUserRequest Request;
private UserResponse User;
private async Task LoadAsync(LazyLoader _)
{
var user = await HttpClient.GetFromJsonAsync<UserResponse>($"api/users/{Id}", Constants.SerializerOptions);
User = user!;
Request = UserMapper.MapToUpdate(User);
}
private async Task OnSubmitAsync()
{
await HttpClient.PatchAsJsonAsync(
$"/api/users/{User.Id}",
Request
);
await ToastService.SuccessAsync(
"User creation",
$"Successfully updated user {Request.Username}"
);
Navigation.NavigateTo("/users");
}
}

View File

@@ -0,0 +1,112 @@
@page "/users"
@using LucideBlazor
@using ShadcnBlazor.Buttons
@using ShadcnBlazor.DataGrids
@using ShadcnBlazor.Dropdowns
@using ShadcnBlazor.Extras.AlertDialogs
@using ShadcnBlazor.Extras.Toasts
@using ShadcnBlazor.Tabels
@using Moonlight.Shared.Http.Requests
@using Moonlight.Shared.Http.Responses
@using Moonlight.Shared.Http.Responses.Users
@inject HttpClient HttpClient
@inject AlertDialogService AlertDialogService
@inject ToastService ToastService
@inject NavigationManager Navigation
<div class="flex flex-row justify-between">
<div class="flex flex-col">
<h1 class="text-xl font-semibold">Users</h1>
<div class="text-muted-foreground">
Manage users registered in your application
</div>
</div>
<div class="flex flex-row gap-x-1.5">
<Button>
<Slot>
<a href="/users/create" @attributes="context">
<PlusIcon/>
Create
</a>
</Slot>
</Button>
</div>
</div>
<div class="mt-8">
<DataGrid @ref="Grid" TGridItem="UserResponse" Loader="LoadAsync" PageSize="10">
<PropertyColumn HeadClassName="text-left" CellClassName="text-left" Field="u => u.Id"/>
<PropertyColumn HeadClassName="text-left" CellClassName="text-left" IsFilterable="true"
Identifier="@nameof(UserResponse.Username)" Field="u => u.Username"/>
<PropertyColumn HeadClassName="text-left" CellClassName="text-left" IsFilterable="true"
Identifier="@nameof(UserResponse.Email)" Field="u => u.Email"/>
<TemplateColumn>
<CellTemplate>
<TableCell>
<div class="flex flex-row items-center justify-end me-3">
<DropdownMenu>
<DropdownMenuTrigger>
<Slot Context="dropdownSlot">
<Button Size="ButtonSize.IconSm" Variant="ButtonVariant.Ghost" @attributes="dropdownSlot">
<EllipsisIcon/>
</Button>
</Slot>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem OnClick="() => Edit(context)">
Edit
<DropdownMenuShortcut>
<PenIcon/>
</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuItem OnClick="() => DeleteAsync(context)" Variant="DropdownMenuItemVariant.Destructive">
Delete
<DropdownMenuShortcut>
<TrashIcon/>
</DropdownMenuShortcut>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</TableCell>
</CellTemplate>
</TemplateColumn>
</DataGrid>
</div>
@code
{
private DataGrid<UserResponse> Grid;
private async Task<DataGridResponse<UserResponse>> LoadAsync(DataGridRequest<UserResponse> request)
{
var query = $"?startIndex={request.StartIndex}&length={request.Length}";
var filterOptions = request.Filters.Count > 0 ? new FilterOptions(request.Filters) : null;
var response = await HttpClient.GetFromJsonAsync<PagedData<UserResponse>>(
$"api/users{query}&filterOptions={filterOptions}",
Constants.SerializerOptions
);
return new DataGridResponse<UserResponse>(response!.Data, response.TotalLength);
}
private void Edit(UserResponse response) => Navigation.NavigateTo($"/users/{response.Id}");
private async Task DeleteAsync(UserResponse user)
{
await AlertDialogService.ConfirmDangerAsync(
$"Deletion of user {user.Username}",
"Do you really want to delete this user? This action cannot be undone",
async () =>
{
await HttpClient.DeleteAsync($"api/users/{user.Id}");
await ToastService.SuccessAsync("User deletion", $"Successfully deleted user {user.Username}");
await Grid.RefreshAsync();
}
);
}
}

View File

@@ -0,0 +1,9 @@
@using System.Net.Http
@using System.Net.Http.Json
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.AspNetCore.Components.Routing
@using Microsoft.AspNetCore.Components.Web
@using Microsoft.AspNetCore.Components.Web.Virtualization
@using Microsoft.AspNetCore.Components.WebAssembly.Http
@using Microsoft.JSInterop
@using Moonlight.Frontend

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="256px" height="301px" viewBox="0 0 256 301" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" preserveAspectRatio="xMidYMid">
<defs>
<linearGradient x1="2.17771739%" y1="34.7938955%" x2="92.7221942%" y2="91.3419405%" id="linearGradient-1">
<stop stop-color="#41A7EF" offset="0%"></stop>
<stop stop-color="#813DDE" offset="54.2186236%"></stop>
<stop stop-color="#8F2EE2" offset="74.4988788%"></stop>
<stop stop-color="#A11CE6" offset="100%"></stop>
</linearGradient>
</defs>
<g>
<path d="M124.183681,101.699 C124.183681,66.515 136.256681,34.152 156.486681,8.525 C159.197681,5.092 156.787681,0.069 152.412681,0.012 C151.775681,0.004 151.136681,0 150.497681,0 C67.6206813,0 0.390681343,66.99 0.00168134279,149.775 C-0.386318657,232.369 66.4286813,300.195 149.019681,300.988 C189.884681,301.381 227.036681,285.484 254.376681,259.395 C257.519681,256.396 255.841681,251.082 251.548681,250.42 C179.413681,239.291 124.183681,176.949 124.183681,101.699" fill="url(#linearGradient-1)"></path>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB