Implemented template crud, db entities, import/export, ptero and pelican import
This commit is contained in:
75
MoonlightServers.Frontend/Admin/Index.razor
Normal file
75
MoonlightServers.Frontend/Admin/Index.razor
Normal file
@@ -0,0 +1,75 @@
|
||||
@page "/admin/servers"
|
||||
|
||||
@using LucideBlazor
|
||||
@using MoonlightServers.Shared
|
||||
@using ShadcnBlazor.Tab
|
||||
|
||||
@inject IAuthorizationService AuthorizationService
|
||||
@inject NavigationManager Navigation
|
||||
|
||||
@attribute [Authorize(Policy = Permissions.Servers.View)]
|
||||
|
||||
<Tabs DefaultValue="@(Tab ?? "servers")" OnValueChanged="OnTabChanged">
|
||||
<TabsList ClassName="inline-flex w-full lg:w-fit justify-start overflow-x-auto overflow-y-hidden">
|
||||
<TabsTrigger Value="servers" Disabled="@(!ServersAccess.Succeeded)">
|
||||
<ContainerIcon />
|
||||
Servers
|
||||
</TabsTrigger>
|
||||
<TabsTrigger Value="nodes" Disabled="@(!NodesAccess.Succeeded)">
|
||||
<ServerIcon />
|
||||
Nodes
|
||||
</TabsTrigger>
|
||||
<TabsTrigger Value="templates" Disabled="@(!TemplatesAccess.Succeeded)">
|
||||
<Package2Icon />
|
||||
Templates
|
||||
</TabsTrigger>
|
||||
<TabsTrigger Value="manager" Disabled="@(!NodesAccess.Succeeded)">
|
||||
<TableIcon />
|
||||
Manager
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
@if (ServersAccess.Succeeded)
|
||||
{
|
||||
<TabsContent Value="servers">
|
||||
|
||||
</TabsContent>
|
||||
}
|
||||
|
||||
@if (NodesAccess.Succeeded)
|
||||
{
|
||||
<TabsContent Value="nodes">
|
||||
<MoonlightServers.Frontend.Admin.Nodes.Overview />
|
||||
</TabsContent>
|
||||
}
|
||||
|
||||
@if (TemplatesAccess.Succeeded)
|
||||
{
|
||||
<TabsContent Value="templates">
|
||||
<MoonlightServers.Frontend.Admin.Templates.Overview />
|
||||
</TabsContent>
|
||||
}
|
||||
</Tabs>
|
||||
|
||||
@code
|
||||
{
|
||||
[Parameter]
|
||||
[SupplyParameterFromQuery(Name = "tab")]
|
||||
public string? Tab { get; set; }
|
||||
|
||||
[CascadingParameter] public Task<AuthenticationState> AuthState { get; set; }
|
||||
|
||||
private AuthorizationResult ServersAccess;
|
||||
private AuthorizationResult NodesAccess;
|
||||
private AuthorizationResult TemplatesAccess;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
var authState = await AuthState;
|
||||
|
||||
ServersAccess = await AuthorizationService.AuthorizeAsync(authState.User, Permissions.Servers.View);
|
||||
NodesAccess = await AuthorizationService.AuthorizeAsync(authState.User, Permissions.Nodes.View);
|
||||
TemplatesAccess = await AuthorizationService.AuthorizeAsync(authState.User, Permissions.Templates.View);
|
||||
}
|
||||
|
||||
private void OnTabChanged(string tab) => Navigation.NavigateTo($"/admin/servers?tab={tab}");
|
||||
}
|
||||
@@ -27,7 +27,7 @@
|
||||
<div class="flex flex-row gap-x-1.5">
|
||||
<Button Variant="ButtonVariant.Secondary">
|
||||
<Slot>
|
||||
<a href="/admin/servers/nodes" @attributes="context">
|
||||
<a href="/admin/servers?tab=nodes" @attributes="context">
|
||||
<ChevronLeftIcon/>
|
||||
Back
|
||||
</a>
|
||||
@@ -50,7 +50,7 @@
|
||||
|
||||
<FieldSet>
|
||||
<FieldGroup>
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-5">
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-8">
|
||||
<Field>
|
||||
<FieldLabel for="nodeName">Name</FieldLabel>
|
||||
<TextInputField
|
||||
@@ -98,7 +98,7 @@
|
||||
$"Successfully created node {Request.Name}"
|
||||
);
|
||||
|
||||
Navigation.NavigateTo("/admin/servers/nodes");
|
||||
Navigation.NavigateTo("/admin/servers?tab=nodes");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -47,7 +47,7 @@
|
||||
<div class="flex flex-row gap-x-1.5">
|
||||
<Button Variant="ButtonVariant.Secondary">
|
||||
<Slot>
|
||||
<a href="/admin/servers/nodes" @attributes="context">
|
||||
<a href="/admin/servers?tab=nodes" @attributes="context">
|
||||
<ChevronLeftIcon/>
|
||||
Back
|
||||
</a>
|
||||
@@ -70,7 +70,7 @@
|
||||
|
||||
<FieldSet>
|
||||
<FieldGroup>
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-5">
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-8">
|
||||
<Field>
|
||||
<FieldLabel for="nodeName">Name</FieldLabel>
|
||||
<TextInputField
|
||||
@@ -148,7 +148,7 @@
|
||||
$"Successfully updated node {Request.Name}"
|
||||
);
|
||||
|
||||
Navigation.NavigateTo("/admin/servers/nodes");
|
||||
Navigation.NavigateTo("/admin/servers?tab=nodes");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
@page "/admin/servers/nodes"
|
||||
|
||||
@using LucideBlazor
|
||||
@using Moonlight.Shared.Http.Requests
|
||||
@using Moonlight.Shared.Http.Responses
|
||||
@@ -15,7 +13,6 @@
|
||||
|
||||
@inject HttpClient HttpClient
|
||||
@inject AlertDialogService AlertDialogService
|
||||
@inject DialogService DialogService
|
||||
@inject ToastService ToastService
|
||||
@inject NavigationManager NavigationManager
|
||||
@inject IAuthorizationService AuthorizationService
|
||||
@@ -61,7 +58,6 @@
|
||||
</CellTemplate>
|
||||
</TemplateColumn>
|
||||
<PropertyColumn Title="HTTP Endpoint"
|
||||
Identifier="@nameof(NodeDto.HttpEndpointUrl)"
|
||||
Field="u => u.HttpEndpointUrl"/>
|
||||
<TemplateColumn>
|
||||
<CellTemplate>
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
@using LucideBlazor
|
||||
@using MoonlightServers.Shared.Admin.Templates
|
||||
@using ShadcnBlazor.Buttons
|
||||
@using ShadcnBlazor.Inputs
|
||||
@using ShadcnBlazor.Tabels
|
||||
|
||||
@inherits Editor<List<MoonlightServers.Shared.Admin.Templates.UpdateConfigurationFileMappingDto>>
|
||||
|
||||
<div class="rounded-md bg-card shadow-sm border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>
|
||||
Key
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
Value
|
||||
</TableHead>
|
||||
<TableHead ClassName="w-10"/>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@foreach (var mapping in Value)
|
||||
{
|
||||
<TableRow @key="mapping">
|
||||
<TableCell>
|
||||
<TextInputField @bind-Value="mapping.Key"
|
||||
placeholder="server-port"/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<TextInputField @bind-Value="mapping.Value"
|
||||
placeholder="{{SERVER_PORT}}"/>
|
||||
</TableCell>
|
||||
<TableCell ClassName="text-right pr-4">
|
||||
<Button @onclick="() => DeleteAsync(mapping)"
|
||||
Size="ButtonSize.Icon"
|
||||
Variant="ButtonVariant.Destructive">
|
||||
<Trash2Icon/>
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
}
|
||||
<TableRow>
|
||||
<TableCell colspan="999999">
|
||||
<div class="flex justify-end">
|
||||
<Button
|
||||
@onclick="AddAsync"
|
||||
Variant="ButtonVariant.Outline"
|
||||
Size="ButtonSize.Sm">
|
||||
<PlusIcon/>
|
||||
Add Mapping
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
@code
|
||||
{
|
||||
private async Task DeleteAsync(UpdateConfigurationFileMappingDto mappingDto)
|
||||
{
|
||||
Value.Remove(mappingDto);
|
||||
await ValueChanged.InvokeAsync(Value);
|
||||
}
|
||||
|
||||
private async Task AddAsync()
|
||||
{
|
||||
Value.Add(new UpdateConfigurationFileMappingDto()
|
||||
{
|
||||
Key = "Change me",
|
||||
Value = "{{CHANGE_ME}}"
|
||||
});
|
||||
|
||||
await ValueChanged.InvokeAsync(Value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
@using LucideBlazor
|
||||
@using MoonlightServers.Shared.Admin.Templates
|
||||
@using ShadcnBlazor.Accordions
|
||||
@using ShadcnBlazor.Buttons
|
||||
@using ShadcnBlazor.Fields
|
||||
@using ShadcnBlazor.Inputs
|
||||
|
||||
<div class="mb-5 flex justify-end">
|
||||
<Button @onclick="Add">
|
||||
<PlusIcon/>
|
||||
Create
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
@foreach (var file in FilesConfig.ConfigurationFiles)
|
||||
{
|
||||
<Accordion ClassName="-space-y-px" Type="AccordionType.Single">
|
||||
<AccordionItem
|
||||
ClassName="overflow-hidden border bg-card px-4 first:rounded-t-lg last:rounded-b-lg last:border-b"
|
||||
Value="element">
|
||||
<AccordionTrigger ClassName="hover:no-underline">
|
||||
<div class="flex items-center gap-3">
|
||||
<FileCogIcon/>
|
||||
<span class="text-left">@file.Path</span>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent ClassName="ps-7">
|
||||
<FieldGroup>
|
||||
<FieldSet>
|
||||
<Field>
|
||||
@{
|
||||
var id = $"configFilePath{file.GetHashCode()}";
|
||||
}
|
||||
<FieldLabel for="@id">Path</FieldLabel>
|
||||
<TextInputField
|
||||
@bind-Value="file.Path"
|
||||
id="@id"/>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
@{
|
||||
var id = $"configFileParser{file.GetHashCode()}";
|
||||
}
|
||||
<FieldLabel for="@id">Parser</FieldLabel>
|
||||
<TextInputField
|
||||
@bind-Value="file.Parser"
|
||||
id="@id"/>
|
||||
</Field>
|
||||
</FieldSet>
|
||||
<FieldSet>
|
||||
<ConfigFileMappingEditor @bind-Value="file.Mappings" />
|
||||
</FieldSet>
|
||||
<Field Orientation="FieldOrientation.Horizontal" ClassName="justify-end">
|
||||
<Button Variant="ButtonVariant.Destructive"
|
||||
@onclick="() => Delete(file)">
|
||||
Delete
|
||||
</Button>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
}
|
||||
</div>
|
||||
|
||||
@code
|
||||
{
|
||||
[Parameter] public UpdateFilesConfigDto FilesConfig { get; set; }
|
||||
|
||||
private void Add()
|
||||
=> FilesConfig.ConfigurationFiles.Add(new());
|
||||
|
||||
private void Delete(UpdateConfigurationFileDto dto)
|
||||
=> FilesConfig.ConfigurationFiles.Remove(dto);
|
||||
}
|
||||
132
MoonlightServers.Frontend/Admin/Templates/Create.razor
Normal file
132
MoonlightServers.Frontend/Admin/Templates/Create.razor
Normal file
@@ -0,0 +1,132 @@
|
||||
@page "/admin/servers/templates/create"
|
||||
|
||||
@using LucideBlazor
|
||||
@using Moonlight.Frontend.Helpers
|
||||
@using MoonlightServers.Shared
|
||||
@using MoonlightServers.Shared.Admin.Templates
|
||||
@using ShadcnBlazor.Buttons
|
||||
@using ShadcnBlazor.Cards
|
||||
@using ShadcnBlazor.Extras.Forms
|
||||
@using ShadcnBlazor.Extras.Toasts
|
||||
@using ShadcnBlazor.Fields
|
||||
@using ShadcnBlazor.Inputs
|
||||
@using ShadcnBlazor.Tab
|
||||
|
||||
@inject HttpClient HttpClient
|
||||
@inject NavigationManager Navigation
|
||||
@inject ToastService ToastService
|
||||
|
||||
<EnhancedEditForm Model="Request" OnValidSubmit="OnSubmitAsync" Context="formContext">
|
||||
|
||||
<div class="flex flex-row justify-between">
|
||||
<div class="flex flex-col">
|
||||
<h1 class="text-xl font-semibold">Create Template</h1>
|
||||
<div class="text-muted-foreground">
|
||||
Create a new template
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-row gap-x-1.5">
|
||||
<Button Variant="ButtonVariant.Secondary">
|
||||
<Slot>
|
||||
<a href="/admin/servers?tab=templates" @attributes="context">
|
||||
<ChevronLeftIcon/>
|
||||
Back
|
||||
</a>
|
||||
</Slot>
|
||||
</Button>
|
||||
<SubmitButton>
|
||||
<CheckIcon/>
|
||||
Continue
|
||||
</SubmitButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DataAnnotationsValidator/>
|
||||
|
||||
<div class="mt-8">
|
||||
<Card>
|
||||
<CardContent>
|
||||
<FieldGroup>
|
||||
<FormValidationSummary/>
|
||||
|
||||
<FieldSet>
|
||||
<FieldGroup>
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-8">
|
||||
<Field>
|
||||
<FieldLabel for="templateName">Name</FieldLabel>
|
||||
<TextInputField
|
||||
@bind-Value="Request.Name"
|
||||
id="templateName"/>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel for="templateAuthor">Author</FieldLabel>
|
||||
<TextInputField
|
||||
@bind-Value="Request.Author"
|
||||
id="templateAuthor"/>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel for="templateVersion">Version</FieldLabel>
|
||||
<TextInputField
|
||||
@bind-Value="Request.Version"
|
||||
id="templateVersion"/>
|
||||
</Field>
|
||||
|
||||
<Field ClassName="col-span-1 lg:col-span-2 xl:col-span-3">
|
||||
<FieldLabel for="templateDescription">Description</FieldLabel>
|
||||
<TextareaInputField
|
||||
@bind-Value="Request.Description"
|
||||
id="templateDescription"/>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel for="templateDonateUrl">Donate URL</FieldLabel>
|
||||
<TextInputField
|
||||
@bind-Value="Request.DonateUrl"
|
||||
id="templateDonateUrl"/>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel for="templateUpdateUrl">Update URL</FieldLabel>
|
||||
<TextInputField
|
||||
@bind-Value="Request.UpdateUrl"
|
||||
id="templateUpdateUrl"/>
|
||||
</Field>
|
||||
</div>
|
||||
</FieldGroup>
|
||||
</FieldSet>
|
||||
</FieldGroup>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
</EnhancedEditForm>
|
||||
|
||||
@code
|
||||
{
|
||||
private CreateTemplateDto Request = new();
|
||||
|
||||
private async Task<bool> OnSubmitAsync(EditContext context, ValidationMessageStore validationMessageStore)
|
||||
{
|
||||
var response = await HttpClient.PostAsJsonAsync(
|
||||
"/api/admin/servers/templates",
|
||||
Request,
|
||||
SerializationContext.Default.Options
|
||||
);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
await ProblemDetailsHelper.HandleProblemDetailsAsync(response, Request, validationMessageStore);
|
||||
return false;
|
||||
}
|
||||
|
||||
await ToastService.SuccessAsync(
|
||||
"Template Creation",
|
||||
$"Successfully created template {Request.Name}"
|
||||
);
|
||||
|
||||
Navigation.NavigateTo("/admin/servers?tab=templates");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
@using Moonlight.Frontend.Helpers
|
||||
@using MoonlightServers.Shared
|
||||
@using MoonlightServers.Shared.Admin.Templates
|
||||
@using ShadcnBlazor.Dialogs
|
||||
@using ShadcnBlazor.Buttons
|
||||
@using ShadcnBlazor.Extras.Forms
|
||||
@using ShadcnBlazor.Extras.Toasts
|
||||
@using ShadcnBlazor.Fields
|
||||
@using ShadcnBlazor.Inputs
|
||||
@using ShadcnBlazor.Switches
|
||||
|
||||
@inherits ShadcnBlazor.Extras.Dialogs.DialogBase
|
||||
|
||||
@inject HttpClient HttpClient
|
||||
@inject ToastService ToastService
|
||||
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create Docker Image</DialogTitle>
|
||||
<DialogDescription>
|
||||
Create a new docker image
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<EnhancedEditForm @ref="Form" Model="Model" OnValidSubmit="OnValidSubmitAsync">
|
||||
<FieldGroup>
|
||||
<DataAnnotationsValidator/>
|
||||
<FormValidationSummary/>
|
||||
|
||||
<FieldSet>
|
||||
<Field>
|
||||
<FieldLabel for="dockerImageDisplayName">Display Name</FieldLabel>
|
||||
<TextInputField
|
||||
@bind-Value="Model.DisplayName"
|
||||
id="dockerImageDisplayName"/>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel for="dockerImageIdentifier">Image Name</FieldLabel>
|
||||
<TextInputField
|
||||
@bind-Value="Model.ImageName"
|
||||
id="dockerImageIdentifier"/>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel for="dockerImageSkipPulling">Skip Pulling</FieldLabel>
|
||||
<Switch id="dockerImageSkipPulling" @bind-Value="Model.SkipPulling" />
|
||||
</Field>
|
||||
</FieldSet>
|
||||
</FieldGroup>
|
||||
</EnhancedEditForm>
|
||||
<DialogFooter>
|
||||
<DialogClose>
|
||||
<Slot>
|
||||
<Button Type="button" Variant="ButtonVariant.Outline" @attributes="context">
|
||||
Cancel
|
||||
</Button>
|
||||
</Slot>
|
||||
</DialogClose>
|
||||
<Button @onclick="() => Form.SubmitAsync()" Type="button">
|
||||
Save
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
||||
@code
|
||||
{
|
||||
[Parameter] public DetailedTemplateDto Template { get; set; }
|
||||
[Parameter] public Func<Task> OnSubmit { get; set; }
|
||||
|
||||
private CreateDockerImageDto Model = new();
|
||||
private EnhancedEditForm Form;
|
||||
|
||||
private async Task<bool> OnValidSubmitAsync(EditContext editContext, ValidationMessageStore validationMessageStore)
|
||||
{
|
||||
var response = await HttpClient.PostAsJsonAsync(
|
||||
$"api/admin/servers/templates/{Template.Id}/dockerImages",
|
||||
Model,
|
||||
SerializationContext.Default.Options
|
||||
);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
await ProblemDetailsHelper.HandleProblemDetailsAsync(response, Model, validationMessageStore);
|
||||
return false;
|
||||
}
|
||||
|
||||
await OnSubmit.Invoke();
|
||||
|
||||
await ToastService.SuccessAsync(
|
||||
"Docker Image Creation",
|
||||
$"Successfully created variable {Model.DisplayName}"
|
||||
);
|
||||
|
||||
await CloseAsync();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
@using Moonlight.Frontend.Helpers
|
||||
@using MoonlightServers.Shared
|
||||
@using MoonlightServers.Shared.Admin.Templates
|
||||
@using ShadcnBlazor.Dialogs
|
||||
@using ShadcnBlazor.Buttons
|
||||
@using ShadcnBlazor.Extras.Forms
|
||||
@using ShadcnBlazor.Extras.Toasts
|
||||
@using ShadcnBlazor.Fields
|
||||
@using ShadcnBlazor.Inputs
|
||||
|
||||
@inherits ShadcnBlazor.Extras.Dialogs.DialogBase
|
||||
|
||||
@inject HttpClient HttpClient
|
||||
@inject ToastService ToastService
|
||||
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create Variable</DialogTitle>
|
||||
<DialogDescription>
|
||||
Create a new variable
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<EnhancedEditForm @ref="Form" Model="Model" OnValidSubmit="OnValidSubmitAsync">
|
||||
<FieldGroup>
|
||||
<DataAnnotationsValidator/>
|
||||
<FormValidationSummary/>
|
||||
|
||||
<FieldSet>
|
||||
<Field>
|
||||
<FieldLabel for="variableDisplayName">Display Name</FieldLabel>
|
||||
<TextInputField
|
||||
@bind-Value="Model.DisplayName"
|
||||
id="variableDisplayName"/>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel for="variableDescription">Description</FieldLabel>
|
||||
<TextareaInputField
|
||||
@bind-Value="Model.Description"
|
||||
id="variableDescription"/>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel for="variableKey">Key</FieldLabel>
|
||||
<TextInputField
|
||||
@bind-Value="Model.EnvName"
|
||||
id="variableKey"/>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel for="variableDefaultValue">Default Value</FieldLabel>
|
||||
<TextInputField
|
||||
@bind-Value="Model.DefaultValue"
|
||||
id="variableDefaultValue"/>
|
||||
</Field>
|
||||
</FieldSet>
|
||||
</FieldGroup>
|
||||
</EnhancedEditForm>
|
||||
<DialogFooter>
|
||||
<DialogClose>
|
||||
<Slot>
|
||||
<Button Type="button" Variant="ButtonVariant.Outline" @attributes="context">
|
||||
Cancel
|
||||
</Button>
|
||||
</Slot>
|
||||
</DialogClose>
|
||||
<Button @onclick="() => Form.SubmitAsync()" Type="button">
|
||||
Save
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
||||
@code
|
||||
{
|
||||
[Parameter] public DetailedTemplateDto Template { get; set; }
|
||||
[Parameter] public Func<Task> OnSubmit { get; set; }
|
||||
|
||||
private CreateVariableDto Model = new();
|
||||
private EnhancedEditForm Form;
|
||||
|
||||
private async Task<bool> OnValidSubmitAsync(EditContext editContext, ValidationMessageStore validationMessageStore)
|
||||
{
|
||||
var response = await HttpClient.PostAsJsonAsync(
|
||||
$"api/admin/servers/templates/{Template.Id}/variables",
|
||||
Model,
|
||||
SerializationContext.Default.Options
|
||||
);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
await ProblemDetailsHelper.HandleProblemDetailsAsync(response, Model, validationMessageStore);
|
||||
return false;
|
||||
}
|
||||
|
||||
await OnSubmit.Invoke();
|
||||
|
||||
await ToastService.SuccessAsync(
|
||||
"Variable Creation",
|
||||
$"Successfully created variable {Model.DisplayName}"
|
||||
);
|
||||
|
||||
await CloseAsync();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
@using System.Text.Json
|
||||
@using LucideBlazor
|
||||
@using Moonlight.Shared.Http.Responses
|
||||
@using MoonlightServers.Shared
|
||||
@using MoonlightServers.Shared.Admin.Templates
|
||||
@using ShadcnBlazor.Extras.Common
|
||||
@using ShadcnBlazor.Extras.Toasts
|
||||
@using ShadcnBlazor.Inputs
|
||||
@using ShadcnBlazor.Buttons
|
||||
@using ShadcnBlazor.Cards
|
||||
@using ShadcnBlazor.Extras.AlertDialogs
|
||||
@using ShadcnBlazor.Extras.Dialogs
|
||||
@using ShadcnBlazor.Fields
|
||||
@using ShadcnBlazor.Selects
|
||||
@using ShadcnBlazor.Switches
|
||||
@using ShadcnBlazor.Tabels
|
||||
|
||||
@inject HttpClient HttpClient
|
||||
@inject ToastService ToastService
|
||||
@inject AlertDialogService AlertDialogService
|
||||
@inject DialogService DialogService
|
||||
|
||||
<LazyLoader @ref="LazyLoader" Load="LoadAsync">
|
||||
<Card ClassName="mb-5">
|
||||
<CardContent>
|
||||
<FieldGroup>
|
||||
<FieldSet>
|
||||
<FieldGroup>
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-8">
|
||||
<Field>
|
||||
<FieldLabel for="templateAllowUserDockerImageChange">Allow User Docker Image Change
|
||||
</FieldLabel>
|
||||
<Switch
|
||||
@bind-Value="Request.AllowUserDockerImageChange"
|
||||
id="templateAllowUserDockerImageChange"/>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel>Default Docker Image</FieldLabel>
|
||||
<Select @bind-Value="DefaultDockerImageBinder">
|
||||
<SelectTrigger>
|
||||
<SelectValue Placeholder="Select a docker image" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@foreach (var (id, dockerImage) in DockerImages)
|
||||
{
|
||||
<SelectItem Value="@id.ToString()">
|
||||
@dockerImage.DisplayName
|
||||
</SelectItem>
|
||||
}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
</div>
|
||||
</FieldGroup>
|
||||
</FieldSet>
|
||||
</FieldGroup>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div class="overflow-hidden rounded-md border bg-card shadow-sm">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>
|
||||
Display Name
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
Identifier
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
Skip Pulling
|
||||
</TableHead>
|
||||
<TableHead ClassName="w-10"/>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@foreach (var (id, dockerImage) in DockerImages)
|
||||
{
|
||||
<TableRow
|
||||
@key="dockerImage">
|
||||
<TableCell>
|
||||
<TextInputField
|
||||
@bind-Value="dockerImage.DisplayName"
|
||||
placeholder="Default Image"/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<TextInputField
|
||||
@bind-Value="dockerImage.ImageName"
|
||||
placeholder="debian:latest"/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Switch @bind-Value="dockerImage.SkipPulling"/>
|
||||
</TableCell>
|
||||
<TableCell ClassName="text-right pr-4">
|
||||
<Button @onclick="() => UpdateAsync(id, dockerImage)"
|
||||
Size="ButtonSize.Icon"
|
||||
Variant="ButtonVariant.Outline">
|
||||
<SaveIcon/>
|
||||
</Button>
|
||||
<Button @onclick="() => DeleteAsync(id, dockerImage)"
|
||||
Size="ButtonSize.Icon"
|
||||
Variant="ButtonVariant.Destructive">
|
||||
<Trash2Icon/>
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
}
|
||||
<TableRow>
|
||||
<TableCell colspan="999999">
|
||||
<div class="flex justify-end">
|
||||
<Button
|
||||
@onclick="AddAsync"
|
||||
Variant="ButtonVariant.Outline"
|
||||
Size="ButtonSize.Sm">
|
||||
<PlusIcon/>
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</LazyLoader>
|
||||
|
||||
@code
|
||||
{
|
||||
[Parameter] public DetailedTemplateDto Template { get; set; }
|
||||
[Parameter] public UpdateTemplateDto Request { get; set; }
|
||||
|
||||
private readonly List<(int, UpdateDockerImageDto)> DockerImages = new();
|
||||
private LazyLoader LazyLoader;
|
||||
|
||||
private string? DefaultDockerImageBinder
|
||||
{
|
||||
get => Request.DefaultDockerImageId?.ToString();
|
||||
set
|
||||
{
|
||||
if (int.TryParse(value, out var intValue))
|
||||
Request.DefaultDockerImageId = intValue;
|
||||
else
|
||||
Request.DefaultDockerImageId = null;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task LoadAsync(LazyLoader _)
|
||||
{
|
||||
DockerImages.Clear();
|
||||
|
||||
var totalAmount = 0;
|
||||
var currentIndex = 0;
|
||||
const int pageSize = 50;
|
||||
|
||||
do
|
||||
{
|
||||
var dockerImages = await HttpClient.GetFromJsonAsync<PagedData<DockerImageDto>>(
|
||||
$"api/admin/servers/templates/{Template.Id}/dockerImages?startIndex={currentIndex}&length={pageSize}"
|
||||
);
|
||||
|
||||
if (dockerImages == null)
|
||||
continue;
|
||||
|
||||
currentIndex += dockerImages.Data.Length;
|
||||
totalAmount = dockerImages.TotalLength;
|
||||
|
||||
DockerImages.AddRange(dockerImages.Data.Select(x => (x.Id, new UpdateDockerImageDto()
|
||||
{
|
||||
DisplayName = x.DisplayName,
|
||||
ImageName = x.ImageName,
|
||||
SkipPulling = x.SkipPulling
|
||||
})));
|
||||
} while (DockerImages.Count < totalAmount);
|
||||
}
|
||||
|
||||
private async Task DeleteAsync(int id, UpdateDockerImageDto dto)
|
||||
{
|
||||
await AlertDialogService.ConfirmDangerAsync(
|
||||
"Docker Image Deletion",
|
||||
$"Do you really want to delete the docker image {dto.DisplayName}? This cannot be undone",
|
||||
async () =>
|
||||
{
|
||||
var response = await HttpClient.DeleteAsync($"api/admin/servers/templates/{Template.Id}/dockerImages/{id}");
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
await ToastService.SuccessAsync(
|
||||
"Docker Image Deletion",
|
||||
$"Successfully deleted docker image {dto.DisplayName}"
|
||||
);
|
||||
|
||||
await LazyLoader.ReloadAsync();
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private async Task UpdateAsync(int id, UpdateDockerImageDto dto)
|
||||
{
|
||||
var response = await HttpClient.PutAsJsonAsync(
|
||||
$"api/admin/servers/templates/{Template.Id}/dockerImages/{id}",
|
||||
dto,
|
||||
SerializationContext.Default.Options
|
||||
);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
try
|
||||
{
|
||||
var problemDetails = await response.Content.ReadFromJsonAsync<ProblemDetails>(
|
||||
Moonlight.Shared.Http.SerializationContext.Default.Options
|
||||
);
|
||||
|
||||
if (problemDetails == null)
|
||||
{
|
||||
// Fallback
|
||||
response.EnsureSuccessStatusCode();
|
||||
return;
|
||||
}
|
||||
|
||||
if (problemDetails.Errors is { Count: > 0 })
|
||||
{
|
||||
var errorMessages = string.Join(
|
||||
", ",
|
||||
problemDetails.Errors.Select(x => $"{x.Key}: {x.Value.FirstOrDefault()}")
|
||||
);
|
||||
|
||||
await ToastService.ErrorAsync(
|
||||
"Docker Image Update",
|
||||
$"{problemDetails.Detail ?? problemDetails.Title} {errorMessages}"
|
||||
);
|
||||
}
|
||||
else
|
||||
{
|
||||
await ToastService.ErrorAsync(
|
||||
"Docker Image Update",
|
||||
$"{problemDetails.Detail ?? problemDetails.Title}"
|
||||
);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// Fallback if unable to deserialize
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
}
|
||||
|
||||
await ToastService.SuccessAsync(
|
||||
"Docker Image Update",
|
||||
$"Successfully updated docker image {dto.DisplayName}"
|
||||
);
|
||||
|
||||
await LazyLoader.ReloadAsync();
|
||||
}
|
||||
|
||||
private async Task AddAsync()
|
||||
{
|
||||
await DialogService.LaunchAsync<CreateDockerImageDialog>(parameters =>
|
||||
{
|
||||
parameters[nameof(CreateDockerImageDialog.Template)] = Template;
|
||||
parameters[nameof(CreateDockerImageDialog.OnSubmit)] = async () => { await LazyLoader.ReloadAsync(); };
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
@using LucideBlazor
|
||||
@using ShadcnBlazor.Inputs
|
||||
@using ShadcnBlazor.Tabels
|
||||
@using ShadcnBlazor.Buttons
|
||||
|
||||
@inherits Editor<List<string>>
|
||||
|
||||
<div class="rounded-md bg-card shadow-sm border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>
|
||||
Online Text
|
||||
</TableHead>
|
||||
<TableHead ClassName="w-10"/>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@foreach (var command in Value)
|
||||
{
|
||||
<TableRow
|
||||
@key="command">
|
||||
<TableCell>
|
||||
<TextInputField Value="@command"
|
||||
ValueExpression="() => command" disabled/>
|
||||
</TableCell>
|
||||
<TableCell ClassName="text-right pr-4">
|
||||
<Button @onclick="() => DeleteAsync(command)"
|
||||
Size="ButtonSize.Icon"
|
||||
Variant="ButtonVariant.Destructive">
|
||||
<Trash2Icon/>
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
}
|
||||
<TableRow>
|
||||
<TableCell colspan="999999">
|
||||
<div class="flex justify-end gap-1">
|
||||
<TextInputField ClassName="h-8"
|
||||
@bind-Value="Input"
|
||||
placeholder="Enter text..." />
|
||||
<Button
|
||||
@onclick="AddAsync"
|
||||
Variant="ButtonVariant.Outline"
|
||||
Size="ButtonSize.Sm">
|
||||
<PlusIcon/>
|
||||
Add Online Text
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
@code
|
||||
{
|
||||
private string Input;
|
||||
|
||||
private async Task DeleteAsync(string command)
|
||||
{
|
||||
Value.Remove(command);
|
||||
await ValueChanged.InvokeAsync(Value);
|
||||
}
|
||||
|
||||
private async Task AddAsync()
|
||||
{
|
||||
if(string.IsNullOrEmpty(Input))
|
||||
return;
|
||||
|
||||
if(Value.Contains(Input))
|
||||
return;
|
||||
|
||||
Value.Add(Input);
|
||||
Input = "";
|
||||
await ValueChanged.InvokeAsync(Value);
|
||||
}
|
||||
}
|
||||
202
MoonlightServers.Frontend/Admin/Templates/Overview.razor
Normal file
202
MoonlightServers.Frontend/Admin/Templates/Overview.razor
Normal file
@@ -0,0 +1,202 @@
|
||||
@using LucideBlazor
|
||||
@using Moonlight.Shared.Http.Requests
|
||||
@using Moonlight.Shared.Http.Responses
|
||||
@using MoonlightServers.Shared
|
||||
@using MoonlightServers.Shared.Admin.Templates
|
||||
@using ShadcnBlazor.DataGrids
|
||||
@using ShadcnBlazor.Buttons
|
||||
@using ShadcnBlazor.Dropdowns
|
||||
@using ShadcnBlazor.Extras.AlertDialogs
|
||||
@using ShadcnBlazor.Extras.Toasts
|
||||
@using ShadcnBlazor.Tabels
|
||||
|
||||
@inject HttpClient HttpClient
|
||||
@inject AlertDialogService AlertDialogService
|
||||
@inject ToastService ToastService
|
||||
@inject NavigationManager NavigationManager
|
||||
@inject IAuthorizationService AuthorizationService
|
||||
|
||||
<InputFile OnChange="OnFileSelectedAsync" id="import-template" class="hidden" multiple accept=".yml,.yaml,.json"/>
|
||||
|
||||
<div class="flex flex-row justify-between mt-5">
|
||||
<div class="flex flex-col">
|
||||
<h1 class="text-xl font-semibold">Templates</h1>
|
||||
<div class="text-muted-foreground">
|
||||
Manage templates
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-row gap-x-1.5">
|
||||
<Button Variant="ButtonVariant.Outline">
|
||||
<Slot>
|
||||
<label for="import-template" @attributes="context">
|
||||
<HardDriveUploadIcon/>
|
||||
Import
|
||||
</label>
|
||||
</Slot>
|
||||
</Button>
|
||||
<Button>
|
||||
<Slot Context="buttonCtx">
|
||||
<a @attributes="buttonCtx" href="/admin/servers/templates/create"
|
||||
data-disabled="@(!CreateAccess.Succeeded)">
|
||||
<PlusIcon/>
|
||||
Create
|
||||
</a>
|
||||
</Slot>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3">
|
||||
<DataGrid @ref="Grid" TGridItem="TemplateDto" Loader="LoadAsync" PageSize="10" ClassName="bg-card">
|
||||
<PropertyColumn Field="u => u.Id"/>
|
||||
<TemplateColumn IsFilterable="true" Identifier="@nameof(TemplateDto.Name)" Title="@nameof(TemplateDto.Name)">
|
||||
<CellTemplate>
|
||||
<TableCell>
|
||||
<a class="text-primary" href="#"
|
||||
@onclick="() => Edit(context)" @onclick:preventDefault>
|
||||
@context.Name
|
||||
</a>
|
||||
</TableCell>
|
||||
</CellTemplate>
|
||||
</TemplateColumn>
|
||||
<TemplateColumn Title="@nameof(TemplateDto.Description)" HeadClassName="hidden lg:table-cell">
|
||||
<CellTemplate>
|
||||
<TableCell ClassName="hidden lg:table-cell">
|
||||
<div class="truncate max-w-md">@context.Description</div>
|
||||
</TableCell>
|
||||
</CellTemplate>
|
||||
</TemplateColumn>
|
||||
<PropertyColumn Field="u => u.Author"
|
||||
HeadClassName="hidden xl:table-cell"
|
||||
CellClassName="hidden xl:table-cell"/>
|
||||
<PropertyColumn Field="u => u.Version"
|
||||
HeadClassName="hidden xl:table-cell"
|
||||
CellClassName="hidden xl:table-cell"/>
|
||||
<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 SideOffset="2">
|
||||
<DropdownMenuItem OnClick="() => Export(context)">
|
||||
Export
|
||||
<DropdownMenuShortcut>
|
||||
<HardDriveDownloadIcon/>
|
||||
</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem OnClick="() => Edit(context)"
|
||||
Disabled="@(!EditAccess.Succeeded)">
|
||||
Edit
|
||||
<DropdownMenuShortcut>
|
||||
<PenIcon/>
|
||||
</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem OnClick="() => DeleteAsync(context)"
|
||||
Variant="DropdownMenuItemVariant.Destructive"
|
||||
Disabled="@(!DeleteAccess.Succeeded)">
|
||||
Delete
|
||||
<DropdownMenuShortcut>
|
||||
<TrashIcon/>
|
||||
</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</TableCell>
|
||||
</CellTemplate>
|
||||
</TemplateColumn>
|
||||
</DataGrid>
|
||||
</div>
|
||||
|
||||
@code
|
||||
{
|
||||
[CascadingParameter] public Task<AuthenticationState> AuthState { get; set; }
|
||||
|
||||
private DataGrid<TemplateDto> Grid;
|
||||
|
||||
private AuthorizationResult EditAccess;
|
||||
private AuthorizationResult DeleteAccess;
|
||||
private AuthorizationResult CreateAccess;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
var authState = await AuthState;
|
||||
|
||||
EditAccess = await AuthorizationService.AuthorizeAsync(authState.User, Permissions.Templates.Edit);
|
||||
DeleteAccess = await AuthorizationService.AuthorizeAsync(authState.User, Permissions.Templates.Delete);
|
||||
CreateAccess = await AuthorizationService.AuthorizeAsync(authState.User, Permissions.Templates.Create);
|
||||
}
|
||||
|
||||
private async Task<DataGridResponse<TemplateDto>> LoadAsync(DataGridRequest<TemplateDto> 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<TemplateDto>>(
|
||||
$"api/admin/servers/templates{query}&filterOptions={filterOptions}",
|
||||
SerializationContext.Default.Options
|
||||
);
|
||||
|
||||
return new DataGridResponse<TemplateDto>(response!.Data, response.TotalLength);
|
||||
}
|
||||
|
||||
private void Edit(TemplateDto context) => NavigationManager.NavigateTo($"/admin/servers/templates/{context.Id}");
|
||||
|
||||
private void Export(TemplateDto dto) => NavigationManager.NavigateTo($"api/admin/servers/templates/{dto.Id}/export", true);
|
||||
|
||||
private async Task DeleteAsync(TemplateDto context)
|
||||
{
|
||||
await AlertDialogService.ConfirmDangerAsync(
|
||||
"Template Deletion",
|
||||
$"Do you really want to delete the template {context.Name}? This cannot be undone.",
|
||||
async () =>
|
||||
{
|
||||
var response = await HttpClient.DeleteAsync($"api/admin/servers/templates/{context.Id}");
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
await Grid.RefreshAsync();
|
||||
|
||||
await ToastService.SuccessAsync(
|
||||
"Template Deletion",
|
||||
$"Successfully deleted template {context.Name}"
|
||||
);
|
||||
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private async Task OnFileSelectedAsync(InputFileChangeEventArgs eventArgs)
|
||||
{
|
||||
var files = eventArgs.GetMultipleFiles();
|
||||
|
||||
foreach (var browserFile in files)
|
||||
{
|
||||
await using var contentStream = browserFile.OpenReadStream(browserFile.Size);
|
||||
|
||||
var response = await HttpClient.PostAsync(
|
||||
"api/admin/servers/templates/import",
|
||||
new StreamContent(contentStream)
|
||||
);
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var importedTemplate = await response
|
||||
.Content
|
||||
.ReadFromJsonAsync<TemplateDto>(SerializationContext.Default.Options);
|
||||
|
||||
if (importedTemplate == null)
|
||||
continue;
|
||||
|
||||
await Grid.RefreshAsync();
|
||||
await ToastService.SuccessAsync("Template Import", $"Successfully imported template {importedTemplate.Name}");
|
||||
}
|
||||
}
|
||||
}
|
||||
16
MoonlightServers.Frontend/Admin/Templates/ScriptEditor.razor
Normal file
16
MoonlightServers.Frontend/Admin/Templates/ScriptEditor.razor
Normal file
@@ -0,0 +1,16 @@
|
||||
@using ShadcnBlazor.Extras.Editors
|
||||
|
||||
@inherits Editor<string>
|
||||
|
||||
<Editor @ref="Editor" Language="EditorLanguage.None" InitialValue="@Value" />
|
||||
|
||||
@code
|
||||
{
|
||||
private Editor Editor;
|
||||
|
||||
public async Task SubmitAsync()
|
||||
{
|
||||
Value = await Editor.GetValueAsync();
|
||||
await ValueChanged.InvokeAsync(Value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
@using LucideBlazor
|
||||
@using MoonlightServers.Shared.Admin.Templates
|
||||
@using ShadcnBlazor.Buttons
|
||||
@using ShadcnBlazor.Inputs
|
||||
@using ShadcnBlazor.Tabels
|
||||
|
||||
@inherits Editor<List<MoonlightServers.Shared.Admin.Templates.UpdateStartupCommandDto>>
|
||||
|
||||
<div class="rounded-md bg-card shadow-sm border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>
|
||||
Display Name
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
Command
|
||||
</TableHead>
|
||||
<TableHead ClassName="w-10"/>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@foreach (var command in Value)
|
||||
{
|
||||
<TableRow
|
||||
@key="command">
|
||||
<TableCell>
|
||||
<TextInputField @bind-Value="command.DisplayName"
|
||||
placeholder="Default Command"/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<TextInputField @bind-Value="command.Command"
|
||||
placeholder="java -Xmx{{SERVER_MEMORY}} server.jar"/>
|
||||
</TableCell>
|
||||
<TableCell ClassName="text-right pr-4">
|
||||
<Button @onclick="() => DeleteAsync(command)"
|
||||
Size="ButtonSize.Icon"
|
||||
Variant="ButtonVariant.Destructive">
|
||||
<Trash2Icon/>
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
}
|
||||
<TableRow>
|
||||
<TableCell colspan="999999">
|
||||
<div class="flex justify-end">
|
||||
<Button
|
||||
@onclick="AddAsync"
|
||||
Variant="ButtonVariant.Outline"
|
||||
Size="ButtonSize.Sm">
|
||||
<PlusIcon/>
|
||||
Add Startup Command
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
@code
|
||||
{
|
||||
private async Task DeleteAsync(UpdateStartupCommandDto commandDto)
|
||||
{
|
||||
Value.Remove(commandDto);
|
||||
await ValueChanged.InvokeAsync(Value);
|
||||
}
|
||||
|
||||
private async Task AddAsync()
|
||||
{
|
||||
Value.Add(new UpdateStartupCommandDto()
|
||||
{
|
||||
Command = "Change me",
|
||||
DisplayName = "Change me"
|
||||
});
|
||||
await ValueChanged.InvokeAsync(Value);
|
||||
}
|
||||
}
|
||||
13
MoonlightServers.Frontend/Admin/Templates/TemplateMapper.cs
Normal file
13
MoonlightServers.Frontend/Admin/Templates/TemplateMapper.cs
Normal file
@@ -0,0 +1,13 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using MoonlightServers.Shared.Admin.Templates;
|
||||
using Riok.Mapperly.Abstractions;
|
||||
|
||||
namespace MoonlightServers.Frontend.Admin.Templates;
|
||||
|
||||
[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 TemplateMapper
|
||||
{
|
||||
public static partial UpdateTemplateDto ToRequest(DetailedTemplateDto dto);
|
||||
}
|
||||
297
MoonlightServers.Frontend/Admin/Templates/Update.razor
Normal file
297
MoonlightServers.Frontend/Admin/Templates/Update.razor
Normal file
@@ -0,0 +1,297 @@
|
||||
@page "/admin/servers/templates/{Id:int}"
|
||||
|
||||
@using System.Net
|
||||
@using System.Text.Json
|
||||
@using LucideBlazor
|
||||
@using Moonlight.Frontend.Helpers
|
||||
@using MoonlightServers.Shared
|
||||
@using MoonlightServers.Shared.Admin.Templates
|
||||
@using ShadcnBlazor.Buttons
|
||||
@using ShadcnBlazor.Cards
|
||||
@using ShadcnBlazor.Emptys
|
||||
@using ShadcnBlazor.Extras.Common
|
||||
@using ShadcnBlazor.Extras.Forms
|
||||
@using ShadcnBlazor.Extras.Toasts
|
||||
@using ShadcnBlazor.Fields
|
||||
@using ShadcnBlazor.Inputs
|
||||
@using ShadcnBlazor.Tab
|
||||
|
||||
@inject HttpClient HttpClient
|
||||
@inject NavigationManager Navigation
|
||||
@inject ToastService ToastService
|
||||
|
||||
<LazyLoader Load="LoadAsync">
|
||||
@if (Template == null)
|
||||
{
|
||||
<Empty>
|
||||
<EmptyHeader>
|
||||
<EmptyMedia Variant="EmptyMediaVariant.Icon">
|
||||
<SearchIcon/>
|
||||
</EmptyMedia>
|
||||
<EmptyTitle>Template not found</EmptyTitle>
|
||||
<EmptyDescription>
|
||||
A template with this id cannot be found
|
||||
</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
}
|
||||
else
|
||||
{
|
||||
<EnhancedEditForm Model="Request" OnValidSubmit="OnSubmitAsync" Context="formContext">
|
||||
|
||||
<div class="flex flex-row justify-between">
|
||||
<div class="flex flex-col">
|
||||
<h1 class="text-xl font-semibold">Update Template</h1>
|
||||
<div class="text-muted-foreground">
|
||||
Update template @Template.Name
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-row gap-x-1.5">
|
||||
<Button Variant="ButtonVariant.Secondary">
|
||||
<Slot>
|
||||
<a href="/admin/servers?tab=templates" @attributes="context">
|
||||
<ChevronLeftIcon/>
|
||||
Back
|
||||
</a>
|
||||
</Slot>
|
||||
</Button>
|
||||
<SubmitButton>
|
||||
<CheckIcon/>
|
||||
Continue
|
||||
</SubmitButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-8 space-y-5">
|
||||
|
||||
<DataAnnotationsValidator/>
|
||||
|
||||
<FormValidationSummary/>
|
||||
|
||||
<Tabs DefaultValue="meta">
|
||||
<TabsList ClassName="inline-flex w-full lg:w-fit justify-start overflow-x-auto overflow-y-hidden">
|
||||
<TabsTrigger Value="meta">
|
||||
<IdCardIcon/>
|
||||
Meta
|
||||
</TabsTrigger>
|
||||
<TabsTrigger Value="lifecycle">
|
||||
<PlayIcon/>
|
||||
Lifecycle
|
||||
</TabsTrigger>
|
||||
<TabsTrigger Value="installation">
|
||||
<SquareTerminalIcon/>
|
||||
Installation
|
||||
</TabsTrigger>
|
||||
<TabsTrigger Value="variables">
|
||||
<VariableIcon/>
|
||||
Variables
|
||||
</TabsTrigger>
|
||||
<TabsTrigger Value="dockerImages">
|
||||
<ContainerIcon/>
|
||||
Docker Images
|
||||
</TabsTrigger>
|
||||
<TabsTrigger Value="files">
|
||||
<FileCogIcon/>
|
||||
Files
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent Value="meta">
|
||||
<Card>
|
||||
<CardContent>
|
||||
<FieldGroup>
|
||||
<FieldSet>
|
||||
<FieldGroup>
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-8">
|
||||
<Field>
|
||||
<FieldLabel for="templateName">Name</FieldLabel>
|
||||
<TextInputField
|
||||
@bind-Value="Request.Name"
|
||||
id="templateName"/>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel for="templateAuthor">Author</FieldLabel>
|
||||
<TextInputField
|
||||
@bind-Value="Request.Author"
|
||||
id="templateAuthor"/>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel for="templateVersion">Version</FieldLabel>
|
||||
<TextInputField
|
||||
@bind-Value="Request.Version"
|
||||
id="templateVersion"/>
|
||||
</Field>
|
||||
|
||||
<Field ClassName="col-span-1 lg:col-span-2 xl:col-span-3">
|
||||
<FieldLabel for="templateDescription">Description</FieldLabel>
|
||||
<TextareaInputField
|
||||
@bind-Value="Request.Description"
|
||||
id="templateDescription"/>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel for="templateDonateUrl">Donate URL</FieldLabel>
|
||||
<TextInputField
|
||||
@bind-Value="Request.DonateUrl"
|
||||
id="templateDonateUrl"/>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel for="templateUpdateUrl">Update URL</FieldLabel>
|
||||
<TextInputField
|
||||
@bind-Value="Request.UpdateUrl"
|
||||
id="templateUpdateUrl"/>
|
||||
</Field>
|
||||
</div>
|
||||
</FieldGroup>
|
||||
</FieldSet>
|
||||
</FieldGroup>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent Value="lifecycle">
|
||||
<Card>
|
||||
<CardContent>
|
||||
<FieldGroup>
|
||||
<FieldSet>
|
||||
<FieldGroup>
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-8">
|
||||
<Field ClassName="col-span-1 lg:col-span-2">
|
||||
<FieldLabel>Startup Commands</FieldLabel>
|
||||
<StartupCommandEditor
|
||||
@bind-Value="Request.LifecycleConfig.StartupCommands"/>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel for="templateStopCommand">Stop Command</FieldLabel>
|
||||
<TextInputField
|
||||
@bind-Value="Request.LifecycleConfig.StopCommand"
|
||||
id="templateStopCommand"/>
|
||||
</Field>
|
||||
<Field ClassName="col-span-1 lg:col-span-2">
|
||||
<FieldLabel>Online Texts</FieldLabel>
|
||||
<OnlineTextEditor
|
||||
@bind-Value="Request.LifecycleConfig.OnlineLogPatterns"/>
|
||||
</Field>
|
||||
</div>
|
||||
</FieldGroup>
|
||||
</FieldSet>
|
||||
</FieldGroup>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent Value="installation">
|
||||
<Card>
|
||||
<CardContent>
|
||||
<FieldGroup>
|
||||
<FieldSet>
|
||||
<FieldGroup>
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-8">
|
||||
<Field>
|
||||
<FieldLabel for="templateInstallDockerImage">Docker Image
|
||||
</FieldLabel>
|
||||
<TextInputField
|
||||
@bind-Value="Request.InstallationConfig.DockerImage"
|
||||
id="templateInstallDockerImage"/>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel for="templateInstallShell">Shell</FieldLabel>
|
||||
<TextInputField
|
||||
@bind-Value="Request.InstallationConfig.Shell"
|
||||
id="templateInstallShell"/>
|
||||
</Field>
|
||||
|
||||
<Field ClassName="col-span-1 lg:col-span-2 xl:col-span-3">
|
||||
<FieldLabel>Script</FieldLabel>
|
||||
<ScriptEditor
|
||||
@ref="ScriptEditor"
|
||||
@bind-Value="Request.InstallationConfig.Script"/>
|
||||
</Field>
|
||||
</div>
|
||||
</FieldGroup>
|
||||
</FieldSet>
|
||||
</FieldGroup>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent Value="variables">
|
||||
<VariableListEditor Template="Template"/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent Value="dockerImages">
|
||||
<DockerImageListEditor Request="Request" Template="Template"/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent Value="files">
|
||||
<ConfigFilesEditor FilesConfig="Request.FilesConfig"/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
</EnhancedEditForm>
|
||||
}
|
||||
</LazyLoader>
|
||||
|
||||
@code
|
||||
{
|
||||
[Parameter] public int Id { get; set; }
|
||||
|
||||
private UpdateTemplateDto Request;
|
||||
private DetailedTemplateDto? Template;
|
||||
|
||||
private ScriptEditor ScriptEditor;
|
||||
|
||||
private async Task LoadAsync(LazyLoader _)
|
||||
{
|
||||
// Meta
|
||||
var metaResponse = await HttpClient.GetAsync($"api/admin/servers/templates/{Id}");
|
||||
|
||||
if (!metaResponse.IsSuccessStatusCode)
|
||||
{
|
||||
if (metaResponse.StatusCode == HttpStatusCode.NotFound)
|
||||
return;
|
||||
|
||||
metaResponse.EnsureSuccessStatusCode();
|
||||
return;
|
||||
}
|
||||
|
||||
Template = await metaResponse.Content.ReadFromJsonAsync<DetailedTemplateDto>(
|
||||
SerializationContext.Default.Options
|
||||
);
|
||||
|
||||
if (Template == null)
|
||||
return;
|
||||
|
||||
Request = TemplateMapper.ToRequest(Template);
|
||||
}
|
||||
|
||||
private async Task<bool> OnSubmitAsync(EditContext context, ValidationMessageStore validationMessageStore)
|
||||
{
|
||||
await ScriptEditor.SubmitAsync();
|
||||
|
||||
var response = await HttpClient.PutAsJsonAsync(
|
||||
$"/api/admin/servers/templates/{Id}",
|
||||
Request,
|
||||
SerializationContext.Default.Options
|
||||
);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
await ProblemDetailsHelper.HandleProblemDetailsAsync(response, Request, validationMessageStore);
|
||||
return false;
|
||||
}
|
||||
|
||||
await ToastService.SuccessAsync(
|
||||
"Template Update",
|
||||
$"Successfully updated template {Request.Name}"
|
||||
);
|
||||
|
||||
Navigation.NavigateTo("/admin/servers?tab=templates");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
147
MoonlightServers.Frontend/Admin/Templates/VariableEditor.razor
Normal file
147
MoonlightServers.Frontend/Admin/Templates/VariableEditor.razor
Normal file
@@ -0,0 +1,147 @@
|
||||
@using LucideBlazor
|
||||
@using Moonlight.Frontend.Helpers
|
||||
@using MoonlightServers.Shared
|
||||
@using MoonlightServers.Shared.Admin.Templates
|
||||
@using ShadcnBlazor.Accordions
|
||||
@using ShadcnBlazor.Buttons
|
||||
@using ShadcnBlazor.Extras.AlertDialogs
|
||||
@using ShadcnBlazor.Extras.Forms
|
||||
@using ShadcnBlazor.Extras.Toasts
|
||||
@using ShadcnBlazor.Fields
|
||||
@using ShadcnBlazor.Inputs
|
||||
|
||||
@inject HttpClient HttpClient
|
||||
@inject ToastService ToastService
|
||||
@inject AlertDialogService AlertDialogService
|
||||
|
||||
<Accordion ClassName="-space-y-px" Type="AccordionType.Single">
|
||||
<AccordionItem
|
||||
ClassName="overflow-hidden border bg-card px-4 first:rounded-t-lg last:rounded-b-lg last:border-b"
|
||||
Value="element">
|
||||
<AccordionTrigger ClassName="hover:no-underline">
|
||||
<div class="flex items-center gap-3">
|
||||
<VariableIcon/>
|
||||
<span class="text-left">@Request.DisplayName</span>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent ClassName="ps-7">
|
||||
<EnhancedEditForm Model="Request" OnValidSubmit="OnValidSubmitAsync">
|
||||
<FieldGroup>
|
||||
<DataAnnotationsValidator/>
|
||||
<FormValidationSummary/>
|
||||
|
||||
<FieldSet>
|
||||
<Field>
|
||||
@{
|
||||
var id = $"variableDisplayName{Variable.Id}";
|
||||
}
|
||||
<FieldLabel for="@id">Display Name</FieldLabel>
|
||||
<TextInputField
|
||||
@bind-Value="Request.DisplayName"
|
||||
id="@id"/>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
@{
|
||||
var id = $"variableDescription{Variable.Id}";
|
||||
}
|
||||
<FieldLabel for="@id">Description</FieldLabel>
|
||||
<TextareaInputField
|
||||
@bind-Value="Request.Description"
|
||||
id="@id"/>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
@{
|
||||
var id = $"variableKey{Variable.Id}";
|
||||
}
|
||||
<FieldLabel for="@id">Key</FieldLabel>
|
||||
<TextInputField
|
||||
@bind-Value="Request.EnvName"
|
||||
id="@id"/>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
@{
|
||||
var id = $"variableDefaultValue{Variable.Id}";
|
||||
}
|
||||
<FieldLabel for="@id">Default Value</FieldLabel>
|
||||
<TextInputField
|
||||
@bind-Value="Request.DefaultValue"
|
||||
id="@id"/>
|
||||
</Field>
|
||||
</FieldSet>
|
||||
<Field Orientation="FieldOrientation.Horizontal" ClassName="justify-end">
|
||||
<Button Variant="ButtonVariant.Destructive" @onclick="DeleteAsync">Delete</Button>
|
||||
<SubmitButton>Save changes</SubmitButton>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
</EnhancedEditForm>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
|
||||
@code
|
||||
{
|
||||
[Parameter] public VariableDto Variable { get; set; }
|
||||
[Parameter] public DetailedTemplateDto Template { get; set; }
|
||||
[Parameter] public Func<Task> OnChanged { get; set; }
|
||||
|
||||
private UpdateVariableDto Request;
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
Request = new UpdateVariableDto()
|
||||
{
|
||||
DisplayName = Variable.DisplayName,
|
||||
DefaultValue = Variable.DefaultValue,
|
||||
EnvName = Variable.EnvName,
|
||||
Description = Variable.Description
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<bool> OnValidSubmitAsync(EditContext editContext, ValidationMessageStore validationMessageStore)
|
||||
{
|
||||
var response = await HttpClient.PutAsJsonAsync(
|
||||
$"api/admin/servers/templates/{Template.Id}/variables/{Variable.Id}",
|
||||
Request,
|
||||
SerializationContext.Default.Options
|
||||
);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
await ProblemDetailsHelper.HandleProblemDetailsAsync(response, Request, validationMessageStore);
|
||||
return false;
|
||||
}
|
||||
|
||||
await ToastService.SuccessAsync(
|
||||
"Variable Update",
|
||||
$"Successfully updated variable {Request.DisplayName}"
|
||||
);
|
||||
|
||||
await OnChanged.Invoke();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private async Task DeleteAsync()
|
||||
{
|
||||
await AlertDialogService.ConfirmDangerAsync(
|
||||
"Variable Deletion",
|
||||
$"Do you really want to delete the variable {Variable.DisplayName}? This cannot be undone",
|
||||
async () =>
|
||||
{
|
||||
var response = await HttpClient.DeleteAsync($"api/admin/servers/templates/{Template.Id}/variables/{Variable.Id}");
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
await ToastService.SuccessAsync(
|
||||
"Variable Deletion",
|
||||
$"Variable {Variable.DisplayName} successfully deleted"
|
||||
);
|
||||
|
||||
await OnChanged.Invoke();
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
@using LucideBlazor
|
||||
@using Moonlight.Shared.Http.Responses
|
||||
@using MoonlightServers.Shared.Admin.Templates
|
||||
@using ShadcnBlazor.Accordions
|
||||
@using ShadcnBlazor.Buttons
|
||||
@using ShadcnBlazor.Extras.Common
|
||||
@using ShadcnBlazor.Extras.Dialogs
|
||||
@using ShadcnBlazor.Fields
|
||||
@using ShadcnBlazor.Inputs
|
||||
|
||||
@inject HttpClient HttpClient
|
||||
@inject DialogService DialogService
|
||||
|
||||
<div class="flex flex-row justify-end mb-5">
|
||||
<Button @onclick="LaunchCreateAsync">
|
||||
<PlusIcon />
|
||||
Create
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<LazyLoader @ref="LazyLoader" Load="LoadAsync">
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-8">
|
||||
@foreach (var variable in Variables)
|
||||
{
|
||||
<VariableEditor OnChanged="() => LazyLoader.ReloadAsync()"
|
||||
Template="Template"
|
||||
Variable="variable"
|
||||
@key="variable" />
|
||||
}
|
||||
</div>
|
||||
</LazyLoader>
|
||||
|
||||
@code
|
||||
{
|
||||
[Parameter] public DetailedTemplateDto Template { get; set; }
|
||||
|
||||
private readonly List<VariableDto> Variables = new();
|
||||
private LazyLoader LazyLoader;
|
||||
|
||||
private async Task LoadAsync(LazyLoader _)
|
||||
{
|
||||
Variables.Clear();
|
||||
|
||||
var totalAmount = 0;
|
||||
var currentIndex = 0;
|
||||
const int pageSize = 50;
|
||||
|
||||
do
|
||||
{
|
||||
var variables = await HttpClient.GetFromJsonAsync<PagedData<VariableDto>>(
|
||||
$"api/admin/servers/templates/{Template.Id}/variables?startIndex={currentIndex}&length={pageSize}"
|
||||
);
|
||||
|
||||
if (variables == null)
|
||||
continue;
|
||||
|
||||
currentIndex += variables.Data.Length;
|
||||
totalAmount = variables.TotalLength;
|
||||
|
||||
Variables.AddRange(variables.Data);
|
||||
|
||||
} while (Variables.Count < totalAmount);
|
||||
}
|
||||
|
||||
private async Task LaunchCreateAsync()
|
||||
{
|
||||
await DialogService.LaunchAsync<CreateVariableDialog>(parameters =>
|
||||
{
|
||||
parameters[nameof(CreateVariableDialog.Template)] = Template;
|
||||
parameters[nameof(CreateVariableDialog.OnSubmit)] = async () =>
|
||||
{
|
||||
await LazyLoader.ReloadAsync();
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user