mirror of
https://github.com/CannedNet/CannedNet.git
synced 2026-10-25 15:09:23 -07:00
basic admin panel stuff
This commit is contained in:
@@ -7,6 +7,7 @@
|
||||
<base href="/" />
|
||||
<ResourcePreloader />
|
||||
<link rel="stylesheet" href="@Assets["lib/bootstrap/dist/css/bootstrap.min.css"]" />
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" />
|
||||
<link rel="stylesheet" href="@Assets["app.css"]" />
|
||||
<link rel="stylesheet" href="@Assets["CannedNet.Admin.styles.css"]" />
|
||||
<ImportMap />
|
||||
@@ -15,8 +16,10 @@
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<Routes />
|
||||
<ReconnectModal />
|
||||
<CascadingAuthenticationState>
|
||||
<Routes />
|
||||
<ReconnectModal />
|
||||
</CascadingAuthenticationState>
|
||||
<script src="@Assets["_framework/blazor.web.js"]"></script>
|
||||
</body>
|
||||
|
||||
|
||||
@@ -1,20 +1,30 @@
|
||||
@inherits LayoutComponentBase
|
||||
@using CannedNet.Admin.Services
|
||||
@inherits LayoutComponentBase
|
||||
@inject AdminAuthService AuthService
|
||||
@inject NavigationManager Navigation
|
||||
|
||||
<div class="page">
|
||||
<div class="sidebar">
|
||||
<NavMenu />
|
||||
</div>
|
||||
@{
|
||||
var body = Body;
|
||||
}
|
||||
|
||||
<main>
|
||||
<div class="top-row px-4">
|
||||
<a href="https://learn.microsoft.com/aspnet/core/" target="_blank">About</a>
|
||||
<AuthorizeView>
|
||||
<Authorized>
|
||||
<div class="page">
|
||||
<div class="sidebar">
|
||||
<NavMenu />
|
||||
</div>
|
||||
|
||||
<main>
|
||||
<article class="content px-4">
|
||||
@body
|
||||
</article>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<article class="content px-4">
|
||||
@Body
|
||||
</article>
|
||||
</main>
|
||||
</div>
|
||||
</Authorized>
|
||||
<NotAuthorized>
|
||||
@body
|
||||
</NotAuthorized>
|
||||
</AuthorizeView>
|
||||
|
||||
<div id="blazor-error-ui" data-nosnippet>
|
||||
An unhandled error has occurred.
|
||||
|
||||
@@ -8,23 +8,69 @@
|
||||
|
||||
<div class="nav-scrollable" onclick="document.querySelector('.navbar-toggler').click()">
|
||||
<nav class="nav flex-column">
|
||||
<div class="nav-item px-3">
|
||||
<NavLink class="nav-link" href="" Match="NavLinkMatch.All">
|
||||
<span class="bi bi-house-door-fill-nav-menu" aria-hidden="true"></span> Home
|
||||
</NavLink>
|
||||
</div>
|
||||
<AuthorizeView>
|
||||
<Authorized>
|
||||
<div class="nav-item px-3">
|
||||
<NavLink class="nav-link" href="" Match="NavLinkMatch.All">
|
||||
<span class="bi bi-house-door-fill-nav-menu" aria-hidden="true"></span> Dashboard
|
||||
</NavLink>
|
||||
</div>
|
||||
|
||||
<div class="nav-item px-3">
|
||||
<NavLink class="nav-link" href="counter">
|
||||
<span class="bi bi-plus-square-fill-nav-menu" aria-hidden="true"></span> Counter
|
||||
</NavLink>
|
||||
</div>
|
||||
<div class="nav-section-label px-3 mt-3 mb-1 text-muted small text-uppercase">Setup</div>
|
||||
<div class="nav-item px-3">
|
||||
<NavLink class="nav-link" href="setup/nameserver">
|
||||
<span class="bi bi-server-nav-menu" aria-hidden="true"></span> Nameserver
|
||||
</NavLink>
|
||||
</div>
|
||||
<div class="nav-item px-3">
|
||||
<NavLink class="nav-link" href="setup/accounts">
|
||||
<span class="bi bi-people-fill-nav-menu" aria-hidden="true"></span> Admin Accounts
|
||||
</NavLink>
|
||||
</div>
|
||||
|
||||
<div class="nav-item px-3">
|
||||
<NavLink class="nav-link" href="weather">
|
||||
<span class="bi bi-list-nested-nav-menu" aria-hidden="true"></span> Weather
|
||||
</NavLink>
|
||||
</div>
|
||||
<div class="nav-section-label px-3 mt-3 mb-1 text-muted small text-uppercase">Users</div>
|
||||
<div class="nav-item px-3">
|
||||
<NavLink class="nav-link" href="users/search">
|
||||
<span class="bi bi-search-nav-menu" aria-hidden="true"></span> Search Users
|
||||
</NavLink>
|
||||
</div>
|
||||
|
||||
<div class="nav-section-label px-3 mt-3 mb-1 text-muted small text-uppercase">Rooms</div>
|
||||
<div class="nav-item px-3">
|
||||
<NavLink class="nav-link" href="rooms/search">
|
||||
<span class="bi bi-building-nav-menu" aria-hidden="true"></span> Search Rooms
|
||||
</NavLink>
|
||||
</div>
|
||||
<div class="nav-item px-3">
|
||||
<NavLink class="nav-link" href="rooms/create">
|
||||
<span class="bi bi-plus-circle-fill-nav-menu" aria-hidden="true"></span> Create Room
|
||||
</NavLink>
|
||||
</div>
|
||||
|
||||
<div class="nav-section-label px-3 mt-3 mb-1 text-muted small text-uppercase">Config</div>
|
||||
<div class="nav-item px-3">
|
||||
<NavLink class="nav-link" href="config/nameserver">
|
||||
<span class="bi bi-diagram-3-fill-nav-menu" aria-hidden="true"></span> NS Config
|
||||
</NavLink>
|
||||
</div>
|
||||
<div class="nav-item px-3">
|
||||
<NavLink class="nav-link" href="config/gameconfig">
|
||||
<span class="bi bi-sliders-nav-menu" aria-hidden="true"></span> Game Config
|
||||
</NavLink>
|
||||
</div>
|
||||
<div class="nav-item px-3">
|
||||
<NavLink class="nav-link" href="config/communityboard">
|
||||
<span class="bi bi-megaphone-fill-nav-menu" aria-hidden="true"></span> Community Board
|
||||
</NavLink>
|
||||
</div>
|
||||
|
||||
<div class="nav-section-label px-3 mt-3 mb-1 text-muted small text-uppercase">Storefronts</div>
|
||||
<div class="nav-item px-3">
|
||||
<NavLink class="nav-link" href="storefronts">
|
||||
<span class="bi bi-cart-fill-nav-menu" aria-hidden="true"></span> Storefronts
|
||||
</NavLink>
|
||||
</div>
|
||||
</Authorized>
|
||||
</AuthorizeView>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
}
|
||||
|
||||
.top-row {
|
||||
min-height: 3.5rem;
|
||||
height: 3.5rem;
|
||||
background-color: rgba(0,0,0,0.4);
|
||||
}
|
||||
|
||||
@@ -34,18 +34,6 @@
|
||||
background-size: cover;
|
||||
}
|
||||
|
||||
.bi-house-door-fill-nav-menu {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-house-door-fill' viewBox='0 0 16 16'%3E%3Cpath d='M6.5 14.5v-3.505c0-.245.25-.495.5-.495h2c.25 0 .5.25.5.5v3.5a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5v-7a.5.5 0 0 0-.146-.354L13 5.793V2.5a.5.5 0 0 0-.5-.5h-1a.5.5 0 0 0-.5.5v1.293L8.354 1.146a.5.5 0 0 0-.708 0l-6 6A.5.5 0 0 0 1.5 7.5v7a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5Z'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
.bi-plus-square-fill-nav-menu {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-plus-square-fill' viewBox='0 0 16 16'%3E%3Cpath d='M2 0a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2H2zm6.5 4.5v3h3a.5.5 0 0 1 0 1h-3v3a.5.5 0 0 1-1 0v-3h-3a.5.5 0 0 1 0-1h3v-3a.5.5 0 0 1 1 0z'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
.bi-list-nested-nav-menu {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-list-nested' viewBox='0 0 16 16'%3E%3Cpath fill-rule='evenodd' d='M4.5 11.5A.5.5 0 0 1 5 11h10a.5.5 0 0 1 0 1H5a.5.5 0 0 1-.5-.5zm-2-4A.5.5 0 0 1 3 7h10a.5.5 0 0 1 0 1H3a.5.5 0 0 1-.5-.5zm-2-4A.5.5 0 0 1 1 3h10a.5.5 0 0 1 0 1H1a.5.5 0 0 1-.5-.5z'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
font-size: 0.9rem;
|
||||
padding-bottom: 0.5rem;
|
||||
@@ -59,27 +47,24 @@
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
|
||||
.nav-item ::deep .nav-link {
|
||||
.nav-item ::deep a {
|
||||
color: #d7d7d7;
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
height: 3rem;
|
||||
height: 2.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
line-height: 3rem;
|
||||
width: 100%;
|
||||
line-height: 2.5rem;
|
||||
}
|
||||
|
||||
.nav-item ::deep a.active {
|
||||
background-color: rgba(255,255,255,0.37);
|
||||
color: white;
|
||||
}
|
||||
.nav-item ::deep a.active {
|
||||
background-color: rgba(255,255,255,0.37);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.nav-item ::deep .nav-link:hover {
|
||||
background-color: rgba(255,255,255,0.1);
|
||||
color: white;
|
||||
}
|
||||
.nav-item ::deep a:hover {
|
||||
background-color: rgba(255,255,255,0.1);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.nav-scrollable {
|
||||
display: none;
|
||||
@@ -89,16 +74,19 @@
|
||||
display: block;
|
||||
}
|
||||
|
||||
.nav-section-label {
|
||||
font-size: 0.7rem;
|
||||
letter-spacing: 0.05em;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
@media (min-width: 641px) {
|
||||
.navbar-toggler {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.nav-scrollable {
|
||||
/* Never collapse the sidebar for wide screens */
|
||||
display: block;
|
||||
|
||||
/* Allow sidebar to scroll for tall menus */
|
||||
height: calc(100vh - 3.5rem);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
@using CannedNet.Admin.Services
|
||||
@inject AdminAuthService AuthService
|
||||
@inject NavigationManager Navigation
|
||||
|
||||
@code {
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
if (!AuthService.HasAnyUser())
|
||||
Navigation.NavigateTo("/setup", forceLoad: true);
|
||||
else
|
||||
Navigation.NavigateTo("/login", forceLoad: true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
@page "/change-password"
|
||||
@attribute [Authorize]
|
||||
@rendermode InteractiveServer
|
||||
@using System.Security.Claims
|
||||
@using CannedNet.Admin.Services
|
||||
@inject AdminAuthService AuthService
|
||||
@inject NavigationManager Navigation
|
||||
@inject IJSRuntime JSRuntime
|
||||
|
||||
<PageTitle>Change Password</PageTitle>
|
||||
|
||||
<h3>Change Password</h3>
|
||||
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-header">Change Your Password</div>
|
||||
<div class="card-body">
|
||||
<form>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Current Password</label>
|
||||
<input type="password" class="form-control" @bind="currentPassword" required />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">New Password</label>
|
||||
<input type="password" class="form-control" @bind="newPassword" required minlength="6" />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Confirm New Password</label>
|
||||
<input type="password" class="form-control" @bind="confirmNewPassword" required />
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary" @onclick="DoChangePassword" disabled="@saving">
|
||||
@(saving ? "Changing..." : "Change Password")
|
||||
</button>
|
||||
<a href="/" class="btn btn-outline-secondary ms-2">Cancel</a>
|
||||
@if (!string.IsNullOrEmpty(message))
|
||||
{
|
||||
<div class="alert mt-3 @(message.Contains("Error") ? "alert-danger" : "alert-success")">@message</div>
|
||||
}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[CascadingParameter] private Task<AuthenticationState>? AuthState { get; set; }
|
||||
|
||||
private string currentPassword = "";
|
||||
private string newPassword = "";
|
||||
private string confirmNewPassword = "";
|
||||
private bool saving;
|
||||
private string? message;
|
||||
|
||||
private async Task DoChangePassword()
|
||||
{
|
||||
saving = true;
|
||||
message = null;
|
||||
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(currentPassword))
|
||||
{
|
||||
message = "Current password is required.";
|
||||
return;
|
||||
}
|
||||
if (string.IsNullOrWhiteSpace(newPassword) || newPassword.Length < 6)
|
||||
{
|
||||
message = "New password must be at least 6 characters.";
|
||||
return;
|
||||
}
|
||||
if (newPassword != confirmNewPassword)
|
||||
{
|
||||
message = "New passwords do not match.";
|
||||
return;
|
||||
}
|
||||
|
||||
var state = await AuthState!;
|
||||
var userIdClaim = state.User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
||||
if (userIdClaim == null || !int.TryParse(userIdClaim, out var userId))
|
||||
{
|
||||
message = "Error: Could not identify user.";
|
||||
return;
|
||||
}
|
||||
|
||||
if (!AuthService.ChangePassword(userId, currentPassword, newPassword))
|
||||
{
|
||||
message = "Error: Current password is incorrect.";
|
||||
return;
|
||||
}
|
||||
|
||||
message = "Password changed successfully!";
|
||||
currentPassword = "";
|
||||
newPassword = "";
|
||||
confirmNewPassword = "";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
message = $"Error: {ex.Message}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
@page "/config/communityboard"
|
||||
@rendermode InteractiveServer
|
||||
|
||||
<PageTitle>Community Board</PageTitle>
|
||||
|
||||
<h3>Community Board</h3>
|
||||
<p class="text-muted">Edit the community board content including featured players, announcements, Instagram images, and videos.</p>
|
||||
|
||||
@if (loading)
|
||||
{
|
||||
<div class="spinner-border" role="status"></div>
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(error))
|
||||
{
|
||||
<div class="alert alert-danger">@error</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<form>
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">Current Announcement</div>
|
||||
<div class="card-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Message</label>
|
||||
<input type="text" class="form-control" @bind="announcementMessage" />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">More Info URL</label>
|
||||
<input type="text" class="form-control" @bind="announcementUrl" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">Featured Player</div>
|
||||
<div class="card-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Player ID</label>
|
||||
<input type="number" class="form-control" @bind="featuredPlayerId" />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Title Override</label>
|
||||
<input type="text" class="form-control" @bind="featuredPlayerTitle" />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">URL Override</label>
|
||||
<input type="text" class="form-control" @bind="featuredPlayerUrl" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">Videos</div>
|
||||
<div class="card-body">
|
||||
@if (videos.Count == 0)
|
||||
{
|
||||
<p class="text-muted">No videos.</p>
|
||||
}
|
||||
@for (int i = 0; i < videos.Count; i++)
|
||||
{
|
||||
var idx = i;
|
||||
<div class="border rounded p-3 mb-2">
|
||||
<div class="mb-2">
|
||||
<label class="form-label">Title</label>
|
||||
<input type="text" class="form-control" @bind="videos[idx].Title" />
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<label class="form-label">Description</label>
|
||||
<textarea class="form-control" rows="2" @bind="videos[idx].Description"></textarea>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<label class="form-label">Source URL</label>
|
||||
<input type="text" class="form-control" @bind="videos[idx].SourceUrl" />
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<label class="form-label">Blob Name</label>
|
||||
<input type="text" class="form-control" @bind="videos[idx].BlobName" />
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<label class="form-label">Thumbnail Blob</label>
|
||||
<input type="text" class="form-control" @bind="videos[idx].ThumbnailBlobName" />
|
||||
</div>
|
||||
<button type="button" class="btn btn-danger btn-sm" @onclick="() => videos.RemoveAt(idx)">Remove</button>
|
||||
</div>
|
||||
}
|
||||
<button type="button" class="btn btn-outline-primary btn-sm" @onclick="() => videos.Add(new VideoEntry())">Add Video</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<button type="submit" class="btn btn-success" @onclick="SaveFromForm" disabled="@saving">
|
||||
@(saving ? "Saving..." : "Save Changes")
|
||||
</button>
|
||||
@if (!string.IsNullOrEmpty(saveMessage))
|
||||
{
|
||||
<span class="ms-2 @(saveMessage.Contains("Error") ? "text-danger" : "text-success")">@saveMessage</span>
|
||||
}
|
||||
</div>
|
||||
</form>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Inject] private HttpClient Http { get; set; } = default!;
|
||||
|
||||
private bool loading = true;
|
||||
private bool saving;
|
||||
private string? error;
|
||||
private string? saveMessage;
|
||||
|
||||
private string announcementMessage = "";
|
||||
private string announcementUrl = "";
|
||||
private int featuredPlayerId;
|
||||
private string? featuredPlayerTitle;
|
||||
private string? featuredPlayerUrl;
|
||||
private List<VideoEntry> videos = new();
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var jsonContent = await Http.GetStringAsync("/admin/api/config/communityboard");
|
||||
|
||||
using var doc = JsonDocument.Parse(jsonContent);
|
||||
var root = doc.RootElement;
|
||||
|
||||
if (root.TryGetProperty("currentAnnouncement", out var ann))
|
||||
{
|
||||
announcementMessage = ann.TryGetProperty("message", out var m) ? m.GetString() ?? "" : "";
|
||||
announcementUrl = ann.TryGetProperty("moreInfoUrl", out var u) ? u.GetString() ?? "" : "";
|
||||
}
|
||||
|
||||
if (root.TryGetProperty("featuredPlayer", out var fp))
|
||||
{
|
||||
featuredPlayerId = fp.TryGetProperty("id", out var id) ? id.GetInt32() : 0;
|
||||
featuredPlayerTitle = fp.TryGetProperty("titleOverride", out var t) ? t.GetString() : null;
|
||||
featuredPlayerUrl = fp.TryGetProperty("urlOverride", out var url) ? url.GetString() : null;
|
||||
}
|
||||
|
||||
if (root.TryGetProperty("videos", out var vids))
|
||||
{
|
||||
videos = new List<VideoEntry>();
|
||||
foreach (var v in vids.EnumerateArray())
|
||||
{
|
||||
videos.Add(new VideoEntry
|
||||
{
|
||||
Title = v.TryGetProperty("title", out var title) ? title.GetString() ?? "" : "",
|
||||
Description = v.TryGetProperty("description", out var desc) ? desc.GetString() ?? "" : "",
|
||||
SourceUrl = v.TryGetProperty("sourceUrl", out var src) ? src.GetString() ?? "" : "",
|
||||
BlobName = v.TryGetProperty("blobName", out var blob) ? blob.GetString() ?? "" : "",
|
||||
ThumbnailBlobName = v.TryGetProperty("thumbnailBlobName", out var thumb) ? thumb.GetString() ?? "" : ""
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
error = $"Failed to load: {ex.Message}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SaveFromForm()
|
||||
{
|
||||
saving = true;
|
||||
saveMessage = null;
|
||||
try
|
||||
{
|
||||
var payload = new
|
||||
{
|
||||
featuredPlayer = new
|
||||
{
|
||||
id = featuredPlayerId,
|
||||
titleOverride = featuredPlayerTitle,
|
||||
urlOverride = featuredPlayerUrl
|
||||
},
|
||||
featuredRoomGroup = new
|
||||
{
|
||||
featuredRoomGroupId = 2,
|
||||
name = "Featured Rooms",
|
||||
rooms = new List<object>()
|
||||
},
|
||||
currentAnnouncement = new
|
||||
{
|
||||
message = announcementMessage,
|
||||
moreInfoUrl = announcementUrl
|
||||
},
|
||||
instagramImages = new List<object>(),
|
||||
videos = videos.Select(v => new
|
||||
{
|
||||
blobName = v.BlobName,
|
||||
title = v.Title,
|
||||
description = v.Description,
|
||||
thumbnailBlobName = v.ThumbnailBlobName,
|
||||
sourceUrl = v.SourceUrl
|
||||
}).ToList()
|
||||
};
|
||||
|
||||
var content = new StringContent(
|
||||
System.Text.Json.JsonSerializer.Serialize(payload, new System.Text.Json.JsonSerializerOptions { WriteIndented = true }),
|
||||
System.Text.Encoding.UTF8, "application/json");
|
||||
var response = await Http.PutAsync("/admin/api/config/communityboard", content);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
saveMessage = "Saved successfully!";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
saveMessage = $"Error: {ex.Message}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
private class VideoEntry
|
||||
{
|
||||
public string Title { get; set; } = "";
|
||||
public string Description { get; set; } = "";
|
||||
public string SourceUrl { get; set; } = "";
|
||||
public string BlobName { get; set; } = "";
|
||||
public string ThumbnailBlobName { get; set; } = "";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
@page "/config/gameconfig"
|
||||
@rendermode InteractiveServer
|
||||
|
||||
<PageTitle>Game Config</PageTitle>
|
||||
|
||||
<h3>Game Config</h3>
|
||||
<p class="text-muted">Edit the gameconfigs.json file that controls various game settings.</p>
|
||||
|
||||
@if (loading)
|
||||
{
|
||||
<div class="spinner-border" role="status"></div>
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(error))
|
||||
{
|
||||
<div class="alert alert-danger">@error</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<ul class="nav nav-tabs mb-3">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link @GetActiveClass("edit")" href="#" @onclick='() => SetTab("edit")' @onclick:preventDefault="true">Raw Editor</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link @GetActiveClass("browse")" href="#" @onclick='() => SetTab("browse")' @onclick:preventDefault="true">Browse</a>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
@if (activeTab == "edit")
|
||||
{
|
||||
<form>
|
||||
<div class="mb-3">
|
||||
<button type="submit" class="btn btn-success" @onclick="Save" disabled="@saving">
|
||||
@(saving ? "Saving..." : "Save Changes")
|
||||
</button>
|
||||
@if (!string.IsNullOrEmpty(saveMessage))
|
||||
{
|
||||
<span class="ms-2 @(saveMessage.Contains("Error") ? "text-danger" : "text-success")">@saveMessage</span>
|
||||
}
|
||||
</div>
|
||||
<textarea class="form-control font-monospace" rows="30" @bind="jsonContent"></textarea>
|
||||
</form>
|
||||
}
|
||||
else if (activeTab == "browse")
|
||||
{
|
||||
<div class="mb-3">
|
||||
<input type="text" class="form-control" placeholder="Filter keys..." @bind="filterText" @bind:event="oninput" />
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-striped">
|
||||
<thead>
|
||||
<tr><th>Key</th><th>Value</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var entry in configEntries.Where(e => string.IsNullOrEmpty(filterText) || e.Key.Contains(filterText, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
<tr>
|
||||
<td class="font-monospace small">@entry.Key</td>
|
||||
<td class="small" style="max-width: 400px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" title="@entry.Value">@entry.Value</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="text-muted small">@configEntries.Count(e => string.IsNullOrEmpty(filterText) || e.Key.Contains(filterText, StringComparison.OrdinalIgnoreCase)) entries</div>
|
||||
}
|
||||
}
|
||||
|
||||
@code {
|
||||
[Inject] private HttpClient Http { get; set; } = default!;
|
||||
|
||||
private bool loading = true;
|
||||
private bool saving;
|
||||
private string? error;
|
||||
private string? saveMessage;
|
||||
private string jsonContent = "";
|
||||
private string activeTab = "browse";
|
||||
private string filterText = "";
|
||||
private List<ConfigEntry> configEntries = new();
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
jsonContent = await Http.GetStringAsync("/admin/api/config/gameconfigs");
|
||||
configEntries = JsonSerializer.Deserialize<List<ConfigEntry>>(jsonContent) ?? new();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
error = $"Failed to load: {ex.Message}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private string GetActiveClass(string tab) => activeTab == tab ? "active" : "";
|
||||
|
||||
private void SetTab(string tab)
|
||||
{
|
||||
activeTab = tab;
|
||||
}
|
||||
|
||||
private async Task Save()
|
||||
{
|
||||
saving = true;
|
||||
saveMessage = null;
|
||||
try
|
||||
{
|
||||
var content = new StringContent(jsonContent, System.Text.Encoding.UTF8, "application/json");
|
||||
var response = await Http.PutAsync("/admin/api/config/gameconfigs", content);
|
||||
response.EnsureSuccessStatusCode();
|
||||
saveMessage = "Saved successfully!";
|
||||
configEntries = JsonSerializer.Deserialize<List<ConfigEntry>>(jsonContent) ?? new();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
saveMessage = $"Error: {ex.Message}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
private class ConfigEntry
|
||||
{
|
||||
public string Key { get; set; } = "";
|
||||
public string Value { get; set; } = "";
|
||||
public string? EndTime { get; set; }
|
||||
public string? StartTime { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
@page "/config/nameserver"
|
||||
@rendermode InteractiveServer
|
||||
@using CannedNet.Admin.Models
|
||||
@using System.Text.Json
|
||||
|
||||
<PageTitle>NS Config</PageTitle>
|
||||
|
||||
<h3>Nameserver (NS) Config</h3>
|
||||
<p class="text-muted">Manage the nameserver endpoints.json file that controls where the game client connects.</p>
|
||||
|
||||
<div class="alert alert-info">
|
||||
This is the same editor as the <a href="setup/nameserver">Setup > Nameserver</a> page.
|
||||
All endpoint URLs are derived from a single base URL.
|
||||
</div>
|
||||
|
||||
@if (loading)
|
||||
{
|
||||
<div class="spinner-border" role="status"></div>
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(error))
|
||||
{
|
||||
<div class="alert alert-danger">@error</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<form>
|
||||
<div class="mb-3">
|
||||
<button type="submit" class="btn btn-success" @onclick="Save" disabled="@saving">
|
||||
@(saving ? "Saving..." : "Save Changes")
|
||||
</button>
|
||||
@if (!string.IsNullOrEmpty(saveMessage))
|
||||
{
|
||||
<span class="@(saveError ? "text-danger" : "text-success") ms-2">@saveMessage</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="baseUrl">Base URL:</label>
|
||||
<input id="baseUrl" class="form-control font-monospace" @bind="baseUrl" placeholder="https://cannednet.lapis.codes" />
|
||||
<div class="form-text">All endpoint URLs will use this as the base.</div>
|
||||
</div>
|
||||
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Service</th>
|
||||
<th>Full URL</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var (name, path) in EndpointDefinitions.Endpoints)
|
||||
{
|
||||
<tr>
|
||||
<td><code>@name</code></td>
|
||||
<td><code>@(baseUrl.TrimEnd('/') + path)</code></td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</form>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Inject] private HttpClient Http { get; set; } = default!;
|
||||
|
||||
private bool loading = true;
|
||||
private bool saving;
|
||||
private bool saveError;
|
||||
private string? error;
|
||||
private string? saveMessage;
|
||||
private string baseUrl = "";
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var json = await Http.GetStringAsync("/admin/api/endpoints");
|
||||
baseUrl = EndpointDefinitions.ExtractBaseUrl(json);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
error = $"Failed to load: {ex.Message}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task Save()
|
||||
{
|
||||
saving = true;
|
||||
saveMessage = null;
|
||||
saveError = false;
|
||||
try
|
||||
{
|
||||
var json = EndpointDefinitions.BuildEndpointsJson(baseUrl);
|
||||
var content = new StringContent(json, System.Text.Encoding.UTF8, "application/json");
|
||||
var response = await Http.PutAsync("/admin/api/endpoints", content);
|
||||
response.EnsureSuccessStatusCode();
|
||||
saveMessage = "Saved successfully!";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
saveMessage = $"Error: {ex.Message}";
|
||||
saveError = true;
|
||||
}
|
||||
finally
|
||||
{
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
@page "/counter"
|
||||
@rendermode InteractiveServer
|
||||
|
||||
<PageTitle>Counter</PageTitle>
|
||||
|
||||
<h1>Counter</h1>
|
||||
|
||||
<p role="status">Current count: @currentCount</p>
|
||||
|
||||
<button class="btn btn-primary" @onclick="IncrementCount">Click me</button>
|
||||
|
||||
@code {
|
||||
private int currentCount = 0;
|
||||
|
||||
private void IncrementCount()
|
||||
{
|
||||
currentCount++;
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
@page "/Error"
|
||||
@using System.Diagnostics
|
||||
@rendermode InteractiveServer
|
||||
|
||||
<PageTitle>Error</PageTitle>
|
||||
|
||||
<h1 class="text-danger">Error.</h1>
|
||||
<h2 class="text-danger">An error occurred while processing your request.</h2>
|
||||
|
||||
@if (ShowRequestId)
|
||||
@if (!string.IsNullOrEmpty(RequestId))
|
||||
{
|
||||
<p>
|
||||
<strong>Request ID:</strong> <code>@RequestId</code>
|
||||
@@ -20,17 +20,11 @@
|
||||
<p>
|
||||
<strong>The Development environment shouldn't be enabled for deployed applications.</strong>
|
||||
It can result in displaying sensitive information from exceptions to end users.
|
||||
For local debugging, enable the <strong>Development</strong> environment by setting the <strong>ASPNETCORE_ENVIRONMENT</strong> environment variable to <strong>Development</strong>
|
||||
For local debugging, you can enable the <strong>Development</strong> environment by setting the <strong>ASPNETCORE_ENVIRONMENT</strong> environment variable to <strong>Development</strong>
|
||||
and restarting the app.
|
||||
</p>
|
||||
|
||||
@code{
|
||||
[CascadingParameter]
|
||||
private HttpContext? HttpContext { get; set; }
|
||||
|
||||
@code {
|
||||
[SupplyParameterFromQuery]
|
||||
private string? RequestId { get; set; }
|
||||
private bool ShowRequestId => !string.IsNullOrEmpty(RequestId);
|
||||
|
||||
protected override void OnInitialized() =>
|
||||
RequestId = Activity.Current?.Id ?? HttpContext?.TraceIdentifier;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,57 @@
|
||||
@page "/"
|
||||
@attribute [Authorize]
|
||||
|
||||
<PageTitle>Home</PageTitle>
|
||||
|
||||
<h1>Hello, world!</h1>
|
||||
<div class="admin-dashboard">
|
||||
<h1 class="mb-4">CannedNet Admin Panel</h1>
|
||||
<p class="lead text-muted">Manage your CannedNet Rec Room private server instance.</p>
|
||||
|
||||
Welcome to your new app.
|
||||
<div class="row g-4 mt-3">
|
||||
<div class="col-md-4">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Setup</h5>
|
||||
<p class="card-text">Configure nameserver endpoints, admin accounts, and authentication.</p>
|
||||
<a href="setup/nameserver" class="btn btn-primary">Go to Setup</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Users</h5>
|
||||
<p class="card-text">Search, edit, and manage player accounts.</p>
|
||||
<a href="users/search" class="btn btn-primary">Manage Users</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Rooms</h5>
|
||||
<p class="card-text">Search, edit, create, and delete rooms and subrooms.</p>
|
||||
<a href="rooms/search" class="btn btn-primary">Manage Rooms</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Config</h5>
|
||||
<p class="card-text">Modify server configuration files: NS config, game configs, community board.</p>
|
||||
<a href="config/nameserver" class="btn btn-primary">Manage Config</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Storefronts</h5>
|
||||
<p class="card-text">Manage storefront items, prices, and create new items.</p>
|
||||
<a href="storefronts" class="btn btn-primary">Manage Storefronts</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
@page "/login"
|
||||
@rendermode InteractiveServer
|
||||
@using CannedNet.Admin.Services
|
||||
@inject AdminAuthService AuthService
|
||||
@inject NavigationManager Navigation
|
||||
|
||||
<PageTitle>Login</PageTitle>
|
||||
|
||||
@if (AuthService.HasAnyUser() == false)
|
||||
{
|
||||
<div class="row justify-content-center mt-5">
|
||||
<div class="col-md-4">
|
||||
<div class="card">
|
||||
<div class="card-body text-center">
|
||||
<h4>No Admin Accounts</h4>
|
||||
<p class="text-muted">No admin accounts exist yet. Complete the first-time setup.</p>
|
||||
<a href="/setup" class="btn btn-primary">Go to Setup</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="row justify-content-center mt-5">
|
||||
<div class="col-md-4">
|
||||
<div class="card">
|
||||
<div class="card-header"><h4 class="mb-0">Admin Login</h4></div>
|
||||
<div class="card-body">
|
||||
<form method="post" action="/admin/login">
|
||||
@if (errorMessage != null)
|
||||
{
|
||||
<div class="alert alert-danger">@errorMessage</div>
|
||||
}
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Username</label>
|
||||
<input type="text" name="username" class="form-control" required />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Password</label>
|
||||
<input type="password" name="password" class="form-control" required />
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary w-100">Login</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
private string? errorMessage;
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
var uri = new Uri(Navigation.Uri);
|
||||
var query = Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(uri.Query);
|
||||
var error = query.TryGetValue("error", out var val) ? val.FirstOrDefault() : null;
|
||||
errorMessage = error switch
|
||||
{
|
||||
"invalid" => "Invalid username or password.",
|
||||
"missing" => "Please enter both username and password.",
|
||||
"expired" => "Your session has expired. Please log in again.",
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
@page "/not-found"
|
||||
@layout MainLayout
|
||||
@rendermode InteractiveServer
|
||||
|
||||
<h3>Not Found</h3>
|
||||
<p>Sorry, the content you are looking for does not exist.</p>
|
||||
<PageTitle>Not Found</PageTitle>
|
||||
|
||||
<h1>Page Not Found</h1>
|
||||
<p>The page you're looking for doesn't exist.</p>
|
||||
<a href="/" class="btn btn-primary">Go Home</a>
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
@page "/rooms/create"
|
||||
@rendermode InteractiveServer
|
||||
|
||||
<PageTitle>Create Room</PageTitle>
|
||||
|
||||
<h3>Create Room</h3>
|
||||
<p class="text-muted">Create a new room and optionally a default subroom.</p>
|
||||
|
||||
<form>
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">Room Details</div>
|
||||
<div class="card-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Room ID (leave 0 for auto)</label>
|
||||
<input type="number" class="form-control" @bind="roomId" />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Name <span class="text-danger">*</span></label>
|
||||
<input type="text" class="form-control" @bind="name" required />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Description</label>
|
||||
<textarea class="form-control" rows="3" @bind="description"></textarea>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Creator Account ID</label>
|
||||
<input type="number" class="form-control" @bind="creatorAccountId" />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Image Name</label>
|
||||
<input type="text" class="form-control" @bind="imageName" />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Accessibility</label>
|
||||
<input type="number" class="form-control" @bind="accessibility" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">SubRoom</div>
|
||||
<div class="card-body">
|
||||
<div class="form-check mb-3">
|
||||
<input type="checkbox" class="form-check-input" @bind="createSubRoom" />
|
||||
<label class="form-check-label">Create a default subroom</label>
|
||||
</div>
|
||||
|
||||
@if (createSubRoom)
|
||||
{
|
||||
<div class="mb-3">
|
||||
<label class="form-label">SubRoom Name</label>
|
||||
<input type="text" class="form-control" @bind="subRoomName" />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Max Players</label>
|
||||
<input type="number" class="form-control" @bind="maxPlayers" />
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">Platform Support</div>
|
||||
<div class="card-body">
|
||||
<div class="form-check">
|
||||
<input type="checkbox" class="form-check-input" @bind="supportsVRLow" />
|
||||
<label class="form-check-label">VR Low</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input type="checkbox" class="form-check-input" @bind="supportsQuest2" />
|
||||
<label class="form-check-label">Quest 2</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input type="checkbox" class="form-check-input" @bind="supportsMobile" />
|
||||
<label class="form-check-label">Mobile</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input type="checkbox" class="form-check-input" @bind="supportsScreens" />
|
||||
<label class="form-check-label">Screens</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input type="checkbox" class="form-check-input" @bind="supportsWalkVR" />
|
||||
<label class="form-check-label">Walk VR</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input type="checkbox" class="form-check-input" @bind="supportsTeleportVR" />
|
||||
<label class="form-check-label">Teleport VR</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input type="checkbox" class="form-check-input" @bind="supportsJuniors" />
|
||||
<label class="form-check-label">Supports Juniors</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary" @onclick="CreateRoom" disabled="@creating">
|
||||
@(creating ? "Creating..." : "Create Room")
|
||||
</button>
|
||||
<a href="rooms/search" class="btn btn-outline-secondary ms-2">Cancel</a>
|
||||
|
||||
@if (!string.IsNullOrEmpty(message))
|
||||
{
|
||||
<div class="alert mt-3 @(message.Contains("Error") ? "alert-danger" : "alert-success")">@message</div>
|
||||
}
|
||||
|
||||
@if (createdRoom != null)
|
||||
{
|
||||
<div class="alert alert-success mt-2">
|
||||
Room created! <a href="rooms/edit/@createdRoom.RoomId">Edit Room #@createdRoom.RoomId</a>
|
||||
</div>
|
||||
}
|
||||
</form>
|
||||
|
||||
@code {
|
||||
[Inject] private HttpClient Http { get; set; } = default!;
|
||||
|
||||
private bool creating;
|
||||
private string? message;
|
||||
private Room? createdRoom;
|
||||
|
||||
private int roomId;
|
||||
private string name = "";
|
||||
private string description = "";
|
||||
private int creatorAccountId;
|
||||
private string imageName = "";
|
||||
private int accessibility;
|
||||
private bool createSubRoom = true;
|
||||
private string subRoomName = "";
|
||||
private int maxPlayers = 16;
|
||||
private bool supportsVRLow = true;
|
||||
private bool supportsQuest2 = true;
|
||||
private bool supportsMobile = true;
|
||||
private bool supportsScreens = true;
|
||||
private bool supportsWalkVR = true;
|
||||
private bool supportsTeleportVR = true;
|
||||
private bool supportsJuniors = true;
|
||||
|
||||
private async Task CreateRoom()
|
||||
{
|
||||
creating = true;
|
||||
message = null;
|
||||
try
|
||||
{
|
||||
var payload = new
|
||||
{
|
||||
roomId = roomId > 0 ? roomId : 0,
|
||||
name,
|
||||
description,
|
||||
creatorAccountId,
|
||||
imageName,
|
||||
accessibility,
|
||||
createSubRoom,
|
||||
subRoomName = createSubRoom ? (string.IsNullOrWhiteSpace(subRoomName) ? name : subRoomName) : "",
|
||||
maxPlayers
|
||||
};
|
||||
var response = await Http.PostAsJsonAsync("/admin/api/rooms", payload);
|
||||
response.EnsureSuccessStatusCode();
|
||||
createdRoom = await response.Content.ReadFromJsonAsync<Room>();
|
||||
message = $"Room '{createdRoom?.Name}' created successfully!";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
message = $"Error: {ex.Message}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
creating = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
@page "/rooms/edit/{RoomId:int}"
|
||||
@rendermode InteractiveServer
|
||||
@inject ImageUrlService Img
|
||||
|
||||
<PageTitle>Edit Room @RoomId</PageTitle>
|
||||
|
||||
<h3>Edit Room #@RoomId</h3>
|
||||
|
||||
@if (loading)
|
||||
{
|
||||
<div class="spinner-border" role="status"></div>
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(error))
|
||||
{
|
||||
<div class="alert alert-danger">@error</div>
|
||||
}
|
||||
else if (roomData == null)
|
||||
{
|
||||
<div class="alert alert-warning">Room not found.</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<form>
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">Room Info</div>
|
||||
<div class="card-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Room ID</label>
|
||||
<input type="number" class="form-control" @bind="editRoomId" />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Name</label>
|
||||
<input type="text" class="form-control" @bind="editName" />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Description</label>
|
||||
<textarea class="form-control" rows="3" @bind="editDescription"></textarea>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Image Name</label>
|
||||
<input type="text" class="form-control" @bind="editImageName" />
|
||||
@if (!string.IsNullOrEmpty(editImageName))
|
||||
{
|
||||
<img src="@Img.GetUrl(editImageName)" alt="preview" class="mt-2 img-thumbnail" style="max-width: 200px;" onerror="this.style.display='none'" />
|
||||
}
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Creator Account ID</label>
|
||||
<input type="number" class="form-control" @bind="editCreatorAccountId" />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Tags (JSON)</label>
|
||||
<textarea class="form-control font-monospace" rows="3" @bind="editTags"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">Settings</div>
|
||||
<div class="card-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">State</label>
|
||||
<input type="number" class="form-control" @bind="editState" />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Accessibility</label>
|
||||
<input type="number" class="form-control" @bind="editAccessibility" />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Min Level</label>
|
||||
<input type="number" class="form-control" @bind="editMinLevel" />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Warning Mask</label>
|
||||
<input type="number" class="form-control" @bind="editWarningMask" />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Custom Warning</label>
|
||||
<input type="text" class="form-control" @bind="editCustomWarning" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">Flags</div>
|
||||
<div class="card-body">
|
||||
<div class="form-check">
|
||||
<input type="checkbox" class="form-check-input" @bind="editIsDorm" />
|
||||
<label class="form-check-label">Is Dorm</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input type="checkbox" class="form-check-input" @bind="editIsRRO" />
|
||||
<label class="form-check-label">Is RRO</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input type="checkbox" class="form-check-input" @bind="editCloningAllowed" />
|
||||
<label class="form-check-label">Cloning Allowed</label>
|
||||
</div>
|
||||
<hr />
|
||||
<h6>Platform Support</h6>
|
||||
<div class="form-check">
|
||||
<input type="checkbox" class="form-check-input" @bind="editSupportsVRLow" />
|
||||
<label class="form-check-label">VR Low</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input type="checkbox" class="form-check-input" @bind="editSupportsQuest2" />
|
||||
<label class="form-check-label">Quest 2</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input type="checkbox" class="form-check-input" @bind="editSupportsMobile" />
|
||||
<label class="form-check-label">Mobile</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input type="checkbox" class="form-check-input" @bind="editSupportsScreens" />
|
||||
<label class="form-check-label">Screens</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input type="checkbox" class="form-check-input" @bind="editSupportsWalkVR" />
|
||||
<label class="form-check-label">Walk VR</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input type="checkbox" class="form-check-input" @bind="editSupportsTeleportVR" />
|
||||
<label class="form-check-label">Teleport VR</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input type="checkbox" class="form-check-input" @bind="editSupportsJuniors" />
|
||||
<label class="form-check-label">Supports Juniors</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (roomData.SubRooms?.Count > 0)
|
||||
{
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">SubRooms (@roomData.SubRooms.Count)</div>
|
||||
<div class="card-body">
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr><th>ID</th><th>SubRoomId</th><th>Name</th><th>Max Players</th><th>Is Sandbox</th><th>Accessibility</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var sub in roomData.SubRooms)
|
||||
{
|
||||
<tr>
|
||||
<td>@sub.Id</td>
|
||||
<td>@sub.SubRoomId</td>
|
||||
<td>@sub.Name</td>
|
||||
<td>@sub.MaxPlayers</td>
|
||||
<td>@sub.IsSandbox</td>
|
||||
<td>@sub.Accessibility</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (roomData.LoadScreens?.Count > 0)
|
||||
{
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">Load Screens (@roomData.LoadScreens.Count)</div>
|
||||
<div class="card-body">
|
||||
@foreach (var ls in roomData.LoadScreens)
|
||||
{
|
||||
<div>@ls.ImageUrl - @ls.Tooltip</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="d-flex gap-2 mb-3">
|
||||
<button type="submit" class="btn btn-success" @onclick="SaveChanges" disabled="@saving">
|
||||
@(saving ? "Saving..." : "Save Changes")
|
||||
</button>
|
||||
<button type="button" class="btn btn-danger" @onclick="DeleteRoom" disabled="@deleting">
|
||||
@(deleting ? "Deleting..." : "Delete Room")
|
||||
</button>
|
||||
<a href="rooms/search" class="btn btn-outline-secondary">Back to Search</a>
|
||||
</div>
|
||||
|
||||
@if (!string.IsNullOrEmpty(saveMessage))
|
||||
{
|
||||
<div class="alert mt-2 @(saveMessage.Contains("Error") || saveMessage.Contains("Failed") ? "alert-danger" : "alert-success")">@saveMessage</div>
|
||||
}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public int RoomId { get; set; }
|
||||
|
||||
private bool loading = true;
|
||||
private bool saving;
|
||||
private bool deleting;
|
||||
private string? error;
|
||||
private string? saveMessage;
|
||||
private RoomDetails? roomData;
|
||||
|
||||
private int editRoomId;
|
||||
private string editName = "";
|
||||
private string editDescription = "";
|
||||
private string editImageName = "";
|
||||
private int editCreatorAccountId;
|
||||
private string editTags = "[]";
|
||||
private int editState;
|
||||
private int editAccessibility;
|
||||
private int editMinLevel;
|
||||
private int editWarningMask;
|
||||
private string? editCustomWarning;
|
||||
private bool editIsDorm;
|
||||
private bool editIsRRO;
|
||||
private bool editCloningAllowed;
|
||||
private bool editSupportsVRLow;
|
||||
private bool editSupportsQuest2;
|
||||
private bool editSupportsMobile;
|
||||
private bool editSupportsScreens;
|
||||
private bool editSupportsWalkVR;
|
||||
private bool editSupportsTeleportVR;
|
||||
private bool editSupportsJuniors;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await LoadRoom();
|
||||
}
|
||||
|
||||
private async Task LoadRoom()
|
||||
{
|
||||
loading = true;
|
||||
error = null;
|
||||
try
|
||||
{
|
||||
roomData = await Http.GetFromJsonAsync<RoomDetails>($"/admin/api/rooms/{RoomId}");
|
||||
|
||||
if (roomData?.Room != null)
|
||||
{
|
||||
var r = roomData.Room;
|
||||
editRoomId = r.RoomId;
|
||||
editName = r.Name;
|
||||
editDescription = r.Description;
|
||||
editImageName = r.ImageName ?? "";
|
||||
editCreatorAccountId = r.CreatorAccountId;
|
||||
editTags = r.Tags ?? "[]";
|
||||
editState = r.State;
|
||||
editAccessibility = r.Accessibility;
|
||||
editMinLevel = r.MinLevel;
|
||||
editWarningMask = r.WarningMask;
|
||||
editCustomWarning = r.CustomWarning;
|
||||
editIsDorm = r.IsDorm;
|
||||
editIsRRO = r.IsRRO;
|
||||
editCloningAllowed = r.CloningAllowed;
|
||||
editSupportsVRLow = r.SupportsVRLow;
|
||||
editSupportsQuest2 = r.SupportsQuest2;
|
||||
editSupportsMobile = r.SupportsMobile;
|
||||
editSupportsScreens = r.SupportsScreens;
|
||||
editSupportsWalkVR = r.SupportsWalkVR;
|
||||
editSupportsTeleportVR = r.SupportsTeleportVR;
|
||||
editSupportsJuniors = r.SupportsJuniors;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
error = $"Failed to load room: {ex.Message}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SaveChanges()
|
||||
{
|
||||
saving = true;
|
||||
saveMessage = null;
|
||||
try
|
||||
{
|
||||
var payload = new
|
||||
{
|
||||
name = editName,
|
||||
description = editDescription,
|
||||
imageName = editImageName,
|
||||
state = editState,
|
||||
accessibility = editAccessibility,
|
||||
isDorm = editIsDorm,
|
||||
isRRO = editIsRRO,
|
||||
cloningAllowed = editCloningAllowed,
|
||||
minLevel = editMinLevel,
|
||||
warningMask = editWarningMask,
|
||||
customWarning = editCustomWarning,
|
||||
supportsVRLow = editSupportsVRLow,
|
||||
supportsQuest2 = editSupportsQuest2,
|
||||
supportsMobile = editSupportsMobile,
|
||||
supportsScreens = editSupportsScreens,
|
||||
supportsWalkVR = editSupportsWalkVR,
|
||||
supportsTeleportVR = editSupportsTeleportVR,
|
||||
supportsJuniors = editSupportsJuniors,
|
||||
tags = editTags
|
||||
};
|
||||
var response = await Http.PutAsJsonAsync($"/admin/api/rooms/{RoomId}", payload);
|
||||
response.EnsureSuccessStatusCode();
|
||||
saveMessage = "Room updated successfully!";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
saveMessage = $"Error: {ex.Message}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DeleteRoom()
|
||||
{
|
||||
if (!await ConfirmDelete()) return;
|
||||
deleting = true;
|
||||
saveMessage = null;
|
||||
try
|
||||
{
|
||||
var response = await Http.DeleteAsync($"/admin/api/rooms/{RoomId}");
|
||||
response.EnsureSuccessStatusCode();
|
||||
saveMessage = "Room deleted successfully!";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
saveMessage = $"Error: {ex.Message}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
deleting = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<bool> ConfirmDelete()
|
||||
{
|
||||
var confirmed = await JSRuntime.InvokeAsync<bool>("confirm", $"Are you sure you want to delete room #{RoomId} and all its subrooms, load screens, promotions, and roles? This cannot be undone.");
|
||||
return confirmed;
|
||||
}
|
||||
|
||||
[Inject] private IJSRuntime JSRuntime { get; set; } = default!;
|
||||
[Inject] private HttpClient Http { get; set; } = default!;
|
||||
|
||||
private class RoomDetails
|
||||
{
|
||||
public Room? Room { get; set; }
|
||||
public List<SubRoom>? SubRooms { get; set; }
|
||||
public List<LoadScreen>? LoadScreens { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
@page "/rooms/search"
|
||||
@rendermode InteractiveServer
|
||||
@inject ImageUrlService Img
|
||||
|
||||
<PageTitle>Search Rooms</PageTitle>
|
||||
|
||||
<h3>Search Rooms</h3>
|
||||
<p class="text-muted">Search for rooms by name, description, or room ID.</p>
|
||||
|
||||
<div class="input-group mb-3">
|
||||
<input type="text" class="form-control" placeholder="Search by name, description, or room ID..." @bind="searchQuery" @bind:event="oninput" />
|
||||
<button class="btn btn-primary" @onclick="DoSearch" disabled="@(string.IsNullOrWhiteSpace(searchQuery) || searching)">
|
||||
@(searching ? "Searching..." : "Search")
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (searching)
|
||||
{
|
||||
<div class="spinner-border" role="status"></div>
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(error))
|
||||
{
|
||||
<div class="alert alert-danger">@error</div>
|
||||
}
|
||||
else if (results != null && results.Count == 0)
|
||||
{
|
||||
<div class="alert alert-info">No rooms found matching "@searchQuery".</div>
|
||||
}
|
||||
else if (results != null)
|
||||
{
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Room ID</th>
|
||||
<th>Image</th>
|
||||
<th>Name</th>
|
||||
<th>Creator</th>
|
||||
<th>State</th>
|
||||
<th>Dorm</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var room in results)
|
||||
{
|
||||
<tr>
|
||||
<td>@room.RoomId</td>
|
||||
<td>
|
||||
@if (!string.IsNullOrEmpty(room.ImageName))
|
||||
{
|
||||
<img src="@Img.GetUrl(room.ImageName)" alt="room" style="width: 60px; height: 40px; object-fit: cover; border-radius: 4px;" onerror="this.style.display='none'" />
|
||||
}
|
||||
</td>
|
||||
<td>@room.Name</td>
|
||||
<td>@room.CreatorAccountId</td>
|
||||
<td>@room.State</td>
|
||||
<td>@(room.IsDorm ? "Yes" : "No")</td>
|
||||
<td>
|
||||
<a href="rooms/edit/@room.RoomId" class="btn btn-sm btn-outline-primary">Edit</a>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Inject] private HttpClient Http { get; set; } = default!;
|
||||
|
||||
private string searchQuery = "";
|
||||
private bool searching;
|
||||
private string? error;
|
||||
private List<Room>? results;
|
||||
|
||||
private async Task DoSearch()
|
||||
{
|
||||
searching = true;
|
||||
error = null;
|
||||
results = null;
|
||||
|
||||
try
|
||||
{
|
||||
results = await Http.GetFromJsonAsync<List<Room>>($"/admin/api/rooms/search?q={Uri.EscapeDataString(searchQuery)}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
error = $"Search failed: {ex.Message}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
searching = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
@page "/setup/accounts"
|
||||
@attribute [Authorize(Policy = "OwnerOnly")]
|
||||
@rendermode InteractiveServer
|
||||
@using CannedNet.Admin.Models
|
||||
@using CannedNet.Admin.Services
|
||||
@inject AdminAuthService AuthService
|
||||
|
||||
<PageTitle>Admin Accounts</PageTitle>
|
||||
|
||||
<h3>Admin Accounts</h3>
|
||||
<p class="text-muted">Manage admin panel accounts. Only the owner can create or delete accounts.</p>
|
||||
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">Create New Admin Account</div>
|
||||
<div class="card-body">
|
||||
<form>
|
||||
<div class="row">
|
||||
<div class="col-md-4 mb-3">
|
||||
<label class="form-label">Username</label>
|
||||
<input type="text" class="form-control" @bind="newUsername" required />
|
||||
</div>
|
||||
<div class="col-md-4 mb-3">
|
||||
<label class="form-label">Password</label>
|
||||
<input type="password" class="form-control" @bind="newPassword" required minlength="6" />
|
||||
</div>
|
||||
<div class="col-md-4 mb-3">
|
||||
<label class="form-label">Confirm Password</label>
|
||||
<input type="password" class="form-control" @bind="newConfirmPassword" required />
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-success" @onclick="CreateAccount" disabled="@creating">
|
||||
@(creating ? "Creating..." : "Create Account")
|
||||
</button>
|
||||
@if (!string.IsNullOrEmpty(createMessage))
|
||||
{
|
||||
<span class="ms-2 @(createMessage.Contains("Error") ? "text-danger" : "text-success")">@createMessage</span>
|
||||
}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">Admin Accounts</div>
|
||||
<div class="card-body">
|
||||
<table class="table table-striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Username</th>
|
||||
<th>Role</th>
|
||||
<th>Created</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var user in AuthService.GetAll())
|
||||
{
|
||||
<tr>
|
||||
<td>@user.Id</td>
|
||||
<td>@user.Username</td>
|
||||
<td>
|
||||
@if (user.IsOwner)
|
||||
{
|
||||
<td>Owner</td>
|
||||
}
|
||||
else
|
||||
{
|
||||
<td>Admin</td>
|
||||
}
|
||||
</td>
|
||||
<td>@user.CreatedAt.ToString("g")</td>
|
||||
<td>
|
||||
@if (!user.IsOwner)
|
||||
{
|
||||
<button class="btn btn-sm btn-danger" @onclick="() => DeleteUser(user.Id)">Delete</button>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private string newUsername = "";
|
||||
private string newPassword = "";
|
||||
private string newConfirmPassword = "";
|
||||
private bool creating;
|
||||
private string? createMessage;
|
||||
|
||||
private async Task CreateAccount()
|
||||
{
|
||||
creating = true;
|
||||
createMessage = null;
|
||||
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(newUsername))
|
||||
{
|
||||
createMessage = "Username is required.";
|
||||
return;
|
||||
}
|
||||
if (string.IsNullOrWhiteSpace(newPassword) || newPassword.Length < 6)
|
||||
{
|
||||
createMessage = "Password must be at least 6 characters.";
|
||||
return;
|
||||
}
|
||||
if (newPassword != newConfirmPassword)
|
||||
{
|
||||
createMessage = "Passwords do not match.";
|
||||
return;
|
||||
}
|
||||
if (AuthService.GetByUsername(newUsername) != null)
|
||||
{
|
||||
createMessage = "Username already exists.";
|
||||
return;
|
||||
}
|
||||
|
||||
AuthService.CreateUser(newUsername, newPassword, isOwner: false);
|
||||
createMessage = "Admin account created successfully!";
|
||||
newUsername = "";
|
||||
newPassword = "";
|
||||
newConfirmPassword = "";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
createMessage = $"Error: {ex.Message}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
creating = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DeleteUser(int id)
|
||||
{
|
||||
var confirmed = await JSRuntime.InvokeAsync<bool>("confirm", "Delete this admin account?");
|
||||
if (!confirmed) return;
|
||||
AuthService.DeleteUser(id);
|
||||
}
|
||||
|
||||
[Inject] private IJSRuntime JSRuntime { get; set; } = default!;
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
@page "/setup"
|
||||
@rendermode InteractiveServer
|
||||
@using CannedNet.Admin.Services
|
||||
@inject AdminAuthService AuthService
|
||||
@inject NavigationManager Navigation
|
||||
|
||||
<PageTitle>First-Time Setup</PageTitle>
|
||||
|
||||
<div class="row justify-content-center mt-5">
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-header"><h4 class="mb-0">First-Time Setup</h4></div>
|
||||
<div class="card-body">
|
||||
@if (completed)
|
||||
{
|
||||
<div class="alert alert-success">
|
||||
<h5>Owner account created!</h5>
|
||||
<p>You can now <a href="/login">log in</a> with your new admin account.</p>
|
||||
</div>
|
||||
}
|
||||
else if (AuthService.HasAnyUser())
|
||||
{
|
||||
<div class="alert alert-info">
|
||||
Admin accounts already exist. <a href="/login">Go to login</a>.
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<p class="text-muted">Create the initial owner account. This account will have full access and can create additional admin accounts.</p>
|
||||
<form>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Username</label>
|
||||
<input type="text" class="form-control" @bind="username" required />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Password</label>
|
||||
<input type="password" class="form-control" @bind="password" required minlength="6" />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Confirm Password</label>
|
||||
<input type="password" class="form-control" @bind="confirmPassword" required />
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary w-100" @onclick="CreateOwner" disabled="@creating">
|
||||
@(creating ? "Creating..." : "Create Owner Account")
|
||||
</button>
|
||||
@if (!string.IsNullOrEmpty(message))
|
||||
{
|
||||
<div class="alert mt-3 @(message.Contains("Error") ? "alert-danger" : "alert-success")">@message</div>
|
||||
}
|
||||
</form>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private string username = "";
|
||||
private string password = "";
|
||||
private string confirmPassword = "";
|
||||
private bool creating;
|
||||
private bool completed;
|
||||
private string? message;
|
||||
|
||||
private async Task CreateOwner()
|
||||
{
|
||||
creating = true;
|
||||
message = null;
|
||||
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(username))
|
||||
{
|
||||
message = "Username is required.";
|
||||
return;
|
||||
}
|
||||
if (string.IsNullOrWhiteSpace(password) || password.Length < 6)
|
||||
{
|
||||
message = "Password must be at least 6 characters.";
|
||||
return;
|
||||
}
|
||||
if (password != confirmPassword)
|
||||
{
|
||||
message = "Passwords do not match.";
|
||||
return;
|
||||
}
|
||||
if (AuthService.HasAnyUser())
|
||||
{
|
||||
message = "Admin accounts already exist.";
|
||||
return;
|
||||
}
|
||||
if (AuthService.GetByUsername(username) != null)
|
||||
{
|
||||
message = "Username already taken.";
|
||||
return;
|
||||
}
|
||||
|
||||
AuthService.CreateUser(username, password, isOwner: true);
|
||||
completed = true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
message = $"Error: {ex.Message}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
creating = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
@page "/setup/nameserver"
|
||||
@rendermode InteractiveServer
|
||||
@using CannedNet.Admin.Models
|
||||
@using System.Text.Json
|
||||
|
||||
<PageTitle>Nameserver Setup</PageTitle>
|
||||
|
||||
<h3>Nameserver Endpoints</h3>
|
||||
<p class="text-muted">Configure the base server URL the game client connects to. All endpoints are derived from this URL.</p>
|
||||
|
||||
@if (loading)
|
||||
{
|
||||
<div class="spinner-border" role="status"></div>
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(error))
|
||||
{
|
||||
<div class="alert alert-danger">@error</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<form>
|
||||
<div class="mb-3">
|
||||
<button type="submit" class="btn btn-success" @onclick="Save" disabled="@saving">
|
||||
@(saving ? "Saving..." : "Save Changes")
|
||||
</button>
|
||||
@if (!string.IsNullOrEmpty(saveMessage))
|
||||
{
|
||||
<span class="@(saveError ? "text-danger" : "text-success") ms-2">@saveMessage</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="baseUrl">Base URL:</label>
|
||||
<input id="baseUrl" class="form-control font-monospace" @bind="baseUrl" placeholder="https://cannednet.lapis.codes" />
|
||||
<div class="form-text">All endpoint URLs will use this as the base.</div>
|
||||
</div>
|
||||
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Service</th>
|
||||
<th>Full URL</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var (name, path) in EndpointDefinitions.Endpoints)
|
||||
{
|
||||
<tr>
|
||||
<td><code>@name</code></td>
|
||||
<td><code>@(baseUrl.TrimEnd('/') + path)</code></td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</form>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Inject] private HttpClient Http { get; set; } = default!;
|
||||
|
||||
private bool loading = true;
|
||||
private bool saving;
|
||||
private bool saveError;
|
||||
private string? error;
|
||||
private string? saveMessage;
|
||||
private string baseUrl = "";
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var json = await Http.GetStringAsync("/admin/api/endpoints");
|
||||
baseUrl = EndpointDefinitions.ExtractBaseUrl(json);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
error = $"Failed to load: {ex.Message}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task Save()
|
||||
{
|
||||
saving = true;
|
||||
saveMessage = null;
|
||||
saveError = false;
|
||||
try
|
||||
{
|
||||
var json = EndpointDefinitions.BuildEndpointsJson(baseUrl);
|
||||
var content = new StringContent(json, System.Text.Encoding.UTF8, "application/json");
|
||||
var response = await Http.PutAsync("/admin/api/endpoints", content);
|
||||
response.EnsureSuccessStatusCode();
|
||||
saveMessage = "Saved successfully!";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
saveMessage = $"Error: {ex.Message}";
|
||||
saveError = true;
|
||||
}
|
||||
finally
|
||||
{
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
@page "/storefronts"
|
||||
@rendermode InteractiveServer
|
||||
|
||||
<PageTitle>Storefronts</PageTitle>
|
||||
|
||||
<h3>Storefronts</h3>
|
||||
<p class="text-muted">Manage storefronts and their items.</p>
|
||||
|
||||
@if (loading)
|
||||
{
|
||||
<div class="spinner-border" role="status"></div>
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(error))
|
||||
{
|
||||
<div class="alert alert-danger">@error</div>
|
||||
}
|
||||
else if (storefronts == null || storefronts.Count == 0)
|
||||
{
|
||||
<div class="alert alert-info">No storefronts found.</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
@foreach (var store in storefronts)
|
||||
{
|
||||
<div class="card mb-4">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<strong>@store.Name</strong>
|
||||
<span class="badge bg-secondary">Type: @store.StorefrontType | Items: @(store.Items?.Count ?? 0)</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
@if (store.Items != null && store.Items.Count > 0)
|
||||
{
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>PurchasableItemId</th>
|
||||
<th>Type</th>
|
||||
<th>Featured</th>
|
||||
<th>Prices</th>
|
||||
<th>Gift Drops</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var item in store.Items)
|
||||
{
|
||||
<tr>
|
||||
<td>@item.Id</td>
|
||||
<td>@item.PurchasableItemId</td>
|
||||
<td>@item.Type</td>
|
||||
<td>@(item.IsFeatured ? "Yes" : "No")</td>
|
||||
<td>
|
||||
@if (item.Prices != null && item.Prices.Count > 0)
|
||||
{
|
||||
@foreach (var p in item.Prices)
|
||||
{
|
||||
<div>Type @p.CurrencyType: @p.Price</div>
|
||||
}
|
||||
}
|
||||
</td>
|
||||
<td>@(item.GiftDrops?.Count ?? 0)</td>
|
||||
<td>
|
||||
<button class="btn btn-sm btn-outline-primary" @onclick="() => EditItem(store.Id, item)">Edit</button>
|
||||
<button class="btn btn-sm btn-outline-danger" @onclick="() => DeleteItem(store.Id, item.Id)">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<p class="text-muted">No items in this storefront.</p>
|
||||
}
|
||||
<button class="btn btn-sm btn-success" @onclick="() => StartCreateItem(store.Id)">+ Add Item</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (showEditModal)
|
||||
{
|
||||
<div class="modal d-block" tabindex="-1" style="background: rgba(0,0,0,0.5);">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">@(editingItem.Id == 0 ? "Create Item" : "Edit Item")</h5>
|
||||
<button type="button" class="btn-close" @onclick="CloseModal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Purchasable Item ID</label>
|
||||
<input type="number" class="form-control" @bind="editPurchasableItemId" />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Type</label>
|
||||
<input type="number" class="form-control" @bind="editType" />
|
||||
</div>
|
||||
<div class="mb-3 form-check">
|
||||
<input type="checkbox" class="form-check-input" @bind="editIsFeatured" />
|
||||
<label class="form-check-label">Is Featured</label>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">New Until (optional, ISO date)</label>
|
||||
<input type="text" class="form-control" @bind="editNewUntil" placeholder="2025-12-31T23:59:59" />
|
||||
</div>
|
||||
<hr />
|
||||
<h6>Prices</h6>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Currency Type</label>
|
||||
<input type="number" class="form-control" @bind="editCurrencyType" />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Price</label>
|
||||
<input type="number" class="form-control" @bind="editPrice" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" @onclick="CloseModal">Cancel</button>
|
||||
<button type="button" class="btn btn-primary" @onclick="SaveItem" disabled="@savingItem">
|
||||
@(savingItem ? "Saving..." : "Save")
|
||||
</button>
|
||||
</div>
|
||||
@if (!string.IsNullOrEmpty(modalMessage))
|
||||
{
|
||||
<div class="alert @(modalMessage.Contains("Error") ? "alert-danger" : "alert-success") mb-0 mx-3">@modalMessage</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
@code {
|
||||
private bool loading = true;
|
||||
private string? error;
|
||||
private List<Storefront>? storefronts;
|
||||
|
||||
private bool showEditModal;
|
||||
private bool savingItem;
|
||||
private string? modalMessage;
|
||||
private StorefrontItem editingItem = new();
|
||||
private int editingStorefrontId;
|
||||
private int editPurchasableItemId;
|
||||
private int editType;
|
||||
private bool editIsFeatured;
|
||||
private string? editNewUntil;
|
||||
private int editCurrencyType;
|
||||
private int editPrice;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await LoadStorefronts();
|
||||
}
|
||||
|
||||
private async Task LoadStorefronts()
|
||||
{
|
||||
loading = true;
|
||||
error = null;
|
||||
try
|
||||
{
|
||||
storefronts = await Http.GetFromJsonAsync<List<Storefront>>("/admin/api/storefronts");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
error = $"Failed to load storefronts: {ex.Message}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void EditItem(int storefrontId, StorefrontItem item)
|
||||
{
|
||||
editingStorefrontId = storefrontId;
|
||||
editingItem = item;
|
||||
editPurchasableItemId = item.PurchasableItemId;
|
||||
editType = item.Type;
|
||||
editIsFeatured = item.IsFeatured;
|
||||
editNewUntil = item.NewUntil?.ToString("o");
|
||||
editCurrencyType = item.Prices?.FirstOrDefault()?.CurrencyType ?? 0;
|
||||
editPrice = item.Prices?.FirstOrDefault()?.Price ?? 0;
|
||||
modalMessage = null;
|
||||
showEditModal = true;
|
||||
}
|
||||
|
||||
private void StartCreateItem(int storefrontId)
|
||||
{
|
||||
editingStorefrontId = storefrontId;
|
||||
editingItem = new StorefrontItem { StorefrontId = storefrontId };
|
||||
editPurchasableItemId = 0;
|
||||
editType = 0;
|
||||
editIsFeatured = false;
|
||||
editNewUntil = null;
|
||||
editCurrencyType = 0;
|
||||
editPrice = 0;
|
||||
modalMessage = null;
|
||||
showEditModal = true;
|
||||
}
|
||||
|
||||
private void CloseModal()
|
||||
{
|
||||
showEditModal = false;
|
||||
modalMessage = null;
|
||||
}
|
||||
|
||||
private async Task SaveItem()
|
||||
{
|
||||
savingItem = true;
|
||||
modalMessage = null;
|
||||
try
|
||||
{
|
||||
if (editingItem.Id == 0)
|
||||
{
|
||||
var payload = new
|
||||
{
|
||||
purchasableItemId = editPurchasableItemId,
|
||||
type = editType,
|
||||
isFeatured = editIsFeatured,
|
||||
newUntil = editNewUntil,
|
||||
price = editPrice,
|
||||
currencyType = editCurrencyType
|
||||
};
|
||||
var response = await Http.PostAsJsonAsync($"/admin/api/storefronts/{editingStorefrontId}/items", payload);
|
||||
response.EnsureSuccessStatusCode();
|
||||
modalMessage = "Item created!";
|
||||
}
|
||||
else
|
||||
{
|
||||
var payload = new
|
||||
{
|
||||
purchasableItemId = editPurchasableItemId,
|
||||
type = editType,
|
||||
isFeatured = editIsFeatured,
|
||||
newUntil = string.IsNullOrEmpty(editNewUntil) ? null : editNewUntil,
|
||||
prices = new[] { new { currencyType = editCurrencyType, price = editPrice } }
|
||||
};
|
||||
var response = await Http.PutAsJsonAsync($"/admin/api/storefronts/items/{editingItem.Id}", payload);
|
||||
response.EnsureSuccessStatusCode();
|
||||
modalMessage = "Item updated!";
|
||||
}
|
||||
|
||||
await LoadStorefronts();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
modalMessage = $"Error: {ex.Message}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
savingItem = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DeleteItem(int storefrontId, int itemId)
|
||||
{
|
||||
var confirmed = await JSRuntime.InvokeAsync<bool>("confirm", "Delete this item?");
|
||||
if (!confirmed) return;
|
||||
|
||||
try
|
||||
{
|
||||
var response = await Http.DeleteAsync($"/admin/api/storefronts/items/{itemId}");
|
||||
response.EnsureSuccessStatusCode();
|
||||
await LoadStorefronts();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
error = $"Failed to delete: {ex.Message}";
|
||||
}
|
||||
}
|
||||
|
||||
[Inject] private IJSRuntime JSRuntime { get; set; } = default!;
|
||||
[Inject] private HttpClient Http { get; set; } = default!;
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
@page "/users/edit/{Id:int}"
|
||||
@rendermode InteractiveServer
|
||||
@inject ImageUrlService Img
|
||||
|
||||
<PageTitle>Edit User @Id</PageTitle>
|
||||
|
||||
<h3>Edit User #@Id</h3>
|
||||
|
||||
@if (loading)
|
||||
{
|
||||
<div class="spinner-border" role="status"></div>
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(error))
|
||||
{
|
||||
<div class="alert alert-danger">@error</div>
|
||||
}
|
||||
else if (data == null)
|
||||
{
|
||||
<div class="alert alert-warning">User not found.</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="row">
|
||||
<div class="col-md-4 mb-4">
|
||||
<div class="card">
|
||||
<div class="card-body text-center">
|
||||
@if (data.Account != null && !string.IsNullOrEmpty(data.Account.ProfileImage))
|
||||
{
|
||||
<img src="@Img.GetUrl(data.Account!.ProfileImage)" alt="Profile" class="img-fluid rounded-circle mb-3" style="max-width: 150px; max-height: 150px; object-fit: cover;" onerror="this.src='data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 width=%22150%22 height=%22150%22><rect fill=%22%23ddd%22 width=%22150%22 height=%22150%22/><text fill=%22%23666%22 font-size=%2250%22 x=%2250%25%22 y=%2255%25%22 text-anchor=%22middle%22>?</text></svg>'" />
|
||||
}
|
||||
<h5 class="mt-2">@data.Account!.Username</h5>
|
||||
<p class="text-muted">@data.Account!.DisplayName</p>
|
||||
<p class="small">ID: @data.Account!.AccountId</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (data.TokenBalances?.Count > 0)
|
||||
{
|
||||
<div class="card mt-3">
|
||||
<div class="card-header">Token Balances</div>
|
||||
<div class="card-body">
|
||||
@foreach (var token in data.TokenBalances)
|
||||
{
|
||||
<div>Type @token.CurrencyType: @token.Balance</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (data.Progression != null)
|
||||
{
|
||||
<div class="card mt-3">
|
||||
<div class="card-header">Progression</div>
|
||||
<div class="card-body">
|
||||
<div>Level: @data.Progression.Level</div>
|
||||
<div>XP: @data.Progression.Xp</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="col-md-8">
|
||||
<form>
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">Account Details</div>
|
||||
<div class="card-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Username</label>
|
||||
<input type="text" class="form-control" @bind="editUsername" />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Display Name</label>
|
||||
<input type="text" class="form-control" @bind="editDisplayName" />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Profile Image Name</label>
|
||||
<input type="text" class="form-control" @bind="editProfileImage" />
|
||||
</div>
|
||||
<div class="mb-3 form-check">
|
||||
<input type="checkbox" class="form-check-input" @bind="editIsJunior" />
|
||||
<label class="form-check-label">Is Junior</label>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Personal Pronouns</label>
|
||||
<input type="number" class="form-control" @bind="editPersonalPronouns" />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Identity Flags</label>
|
||||
<input type="number" class="form-control" @bind="editIdentityFlags" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (!string.IsNullOrEmpty(data.Bio))
|
||||
{
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">Bio</div>
|
||||
<div class="card-body">
|
||||
<p>@data.Bio</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (data.AvatarItems?.Count > 0)
|
||||
{
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">Avatar Items (@data.AvatarItems.Count)</div>
|
||||
<div class="card-body" style="max-height: 300px; overflow-y: auto;">
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Name</th>
|
||||
<th>Type</th>
|
||||
<th>Rarity</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var item in data.AvatarItems)
|
||||
{
|
||||
<tr>
|
||||
<td>@item.Id</td>
|
||||
<td>@item.FriendlyName</td>
|
||||
<td>@item.AvatarItemType</td>
|
||||
<td>@item.Rarity</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (data.Settings?.Count > 0)
|
||||
{
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">Player Settings (@data.Settings.Count)</div>
|
||||
<div class="card-body" style="max-height: 300px; overflow-y: auto;">
|
||||
@foreach (var setting in data.Settings)
|
||||
{
|
||||
<div><strong>@setting.Key:</strong> @setting.Value</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="d-flex gap-2">
|
||||
<button type="submit" class="btn btn-success" @onclick="SaveChanges" disabled="@saving">
|
||||
@(saving ? "Saving..." : "Save Changes")
|
||||
</button>
|
||||
<button type="button" class="btn btn-danger" @onclick="ConfirmAndDeleteUser" disabled="@deleting">
|
||||
@(deleting ? "Deleting..." : "Delete User")
|
||||
</button>
|
||||
<a href="users/search" class="btn btn-outline-secondary">Back to Search</a>
|
||||
</div>
|
||||
|
||||
@if (!string.IsNullOrEmpty(saveMessage))
|
||||
{
|
||||
<div class="alert mt-3 @(saveMessage.Contains("Error") ? "alert-danger" : "alert-success")">@saveMessage</div>
|
||||
}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public int Id { get; set; }
|
||||
[Inject] private IJSRuntime JSRuntime { get; set; } = default!;
|
||||
[Inject] private HttpClient Http { get; set; } = default!;
|
||||
|
||||
private bool loading = true;
|
||||
private bool saving;
|
||||
private bool deleting;
|
||||
private string? error;
|
||||
private string? saveMessage;
|
||||
private AccountDetails? data;
|
||||
|
||||
private string editUsername = "";
|
||||
private string editDisplayName = "";
|
||||
private string editProfileImage = "";
|
||||
private bool editIsJunior;
|
||||
private int editPersonalPronouns;
|
||||
private int editIdentityFlags;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await LoadUser();
|
||||
}
|
||||
|
||||
private async Task LoadUser()
|
||||
{
|
||||
loading = true;
|
||||
error = null;
|
||||
try
|
||||
{
|
||||
data = await Http.GetFromJsonAsync<AccountDetails>($"/admin/api/accounts/{Id}");
|
||||
if (data?.Account != null)
|
||||
{
|
||||
editUsername = data.Account.Username ?? "";
|
||||
editDisplayName = data.Account.DisplayName ?? "";
|
||||
editProfileImage = data.Account.ProfileImage ?? "";
|
||||
editIsJunior = data.Account.IsJunior;
|
||||
editPersonalPronouns = data.Account.PersonalPronouns ?? 0;
|
||||
editIdentityFlags = data.Account.IdentityFlags ?? 0;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
error = $"Failed to load user: {ex.Message}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SaveChanges()
|
||||
{
|
||||
saving = true;
|
||||
saveMessage = null;
|
||||
try
|
||||
{
|
||||
var payload = new
|
||||
{
|
||||
username = editUsername,
|
||||
displayName = editDisplayName,
|
||||
profileImage = editProfileImage,
|
||||
isJunior = editIsJunior,
|
||||
personalPronouns = editPersonalPronouns,
|
||||
identityFlags = editIdentityFlags
|
||||
};
|
||||
var response = await Http.PutAsJsonAsync($"/admin/api/accounts/{Id}", payload);
|
||||
response.EnsureSuccessStatusCode();
|
||||
saveMessage = "User updated successfully!";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
saveMessage = $"Error: {ex.Message}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ConfirmAndDeleteUser()
|
||||
{
|
||||
var confirmed = await JSRuntime.InvokeAsync<bool>("confirm", $"Are you sure you want to delete user #{Id}? This cannot be undone.");
|
||||
if (!confirmed) return;
|
||||
|
||||
deleting = true;
|
||||
saveMessage = null;
|
||||
try
|
||||
{
|
||||
var response = await Http.DeleteAsync($"/admin/api/accounts/{Id}");
|
||||
response.EnsureSuccessStatusCode();
|
||||
saveMessage = "User deleted successfully!";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
saveMessage = $"Error: {ex.Message}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
deleting = false;
|
||||
}
|
||||
}
|
||||
|
||||
private class AccountDetails
|
||||
{
|
||||
public Account? Account { get; set; }
|
||||
public string? Bio { get; set; }
|
||||
public PlayerProgression? Progression { get; set; }
|
||||
public List<AvatarItem>? AvatarItems { get; set; }
|
||||
public List<PlayerSetting>? Settings { get; set; }
|
||||
public List<TokenBalance>? TokenBalances { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
@page "/users/search"
|
||||
@rendermode InteractiveServer
|
||||
@inject ImageUrlService Img
|
||||
|
||||
<PageTitle>Search Users</PageTitle>
|
||||
|
||||
<h3>Search Users</h3>
|
||||
<p class="text-muted">Search for player accounts by username, display name, or account ID.</p>
|
||||
|
||||
<div class="input-group mb-3">
|
||||
<input type="text" class="form-control" placeholder="Search by username, display name, or ID..." @bind="searchQuery" @bind:event="oninput" />
|
||||
<button class="btn btn-primary" @onclick="DoSearch" disabled="@(string.IsNullOrWhiteSpace(searchQuery) || searching)">
|
||||
@(searching ? "Searching..." : "Search")
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (searching)
|
||||
{
|
||||
<div class="spinner-border" role="status"></div>
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(error))
|
||||
{
|
||||
<div class="alert alert-danger">@error</div>
|
||||
}
|
||||
else if (results != null && results.Count == 0)
|
||||
{
|
||||
<div class="alert alert-info">No users found matching "@searchQuery".</div>
|
||||
}
|
||||
else if (results != null)
|
||||
{
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Profile Image</th>
|
||||
<th>Username</th>
|
||||
<th>Display Name</th>
|
||||
<th>Created</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var account in results)
|
||||
{
|
||||
<tr>
|
||||
<td>@account.AccountId</td>
|
||||
<td>
|
||||
@if (!string.IsNullOrEmpty(account.ProfileImage))
|
||||
{
|
||||
<img src="@Img.GetUrl(account.ProfileImage)" alt="pfp" style="width: 40px; height: 40px; object-fit: cover; border-radius: 50%;" onerror="this.style.display='none'" />
|
||||
}
|
||||
</td>
|
||||
<td>@account.Username</td>
|
||||
<td>@account.DisplayName</td>
|
||||
<td>@account.CreatedAt.ToString("g")</td>
|
||||
<td>
|
||||
<a href="users/edit/@account.AccountId" class="btn btn-sm btn-outline-primary">Edit</a>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Inject] private HttpClient Http { get; set; } = default!;
|
||||
|
||||
private string searchQuery = "";
|
||||
private bool searching;
|
||||
private string? error;
|
||||
private List<Account>? results;
|
||||
|
||||
private async Task DoSearch()
|
||||
{
|
||||
searching = true;
|
||||
error = null;
|
||||
results = null;
|
||||
|
||||
try
|
||||
{
|
||||
results = await Http.GetFromJsonAsync<List<Account>>($"/admin/api/accounts/search?q={Uri.EscapeDataString(searchQuery)}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
error = $"Search failed: {ex.Message}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
searching = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
@page "/weather"
|
||||
@attribute [StreamRendering]
|
||||
|
||||
<PageTitle>Weather</PageTitle>
|
||||
|
||||
<h1>Weather</h1>
|
||||
|
||||
<p>This component demonstrates showing data.</p>
|
||||
|
||||
@if (forecasts == null)
|
||||
{
|
||||
<p><em>Loading...</em></p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th aria-label="Temperature in Celsius">Temp. (C)</th>
|
||||
<th aria-label="Temperature in Fahrenheit">Temp. (F)</th>
|
||||
<th>Summary</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var forecast in forecasts)
|
||||
{
|
||||
<tr>
|
||||
<td>@forecast.Date.ToShortDateString()</td>
|
||||
<td>@forecast.TemperatureC</td>
|
||||
<td>@forecast.TemperatureF</td>
|
||||
<td>@forecast.Summary</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
|
||||
@code {
|
||||
private WeatherForecast[]? forecasts;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
// Simulate asynchronous loading to demonstrate streaming rendering
|
||||
await Task.Delay(500);
|
||||
|
||||
var startDate = DateOnly.FromDateTime(DateTime.Now);
|
||||
var summaries = new[] { "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" };
|
||||
forecasts = Enumerable.Range(1, 5).Select(index => new WeatherForecast
|
||||
{
|
||||
Date = startDate.AddDays(index),
|
||||
TemperatureC = Random.Shared.Next(-20, 55),
|
||||
Summary = summaries[Random.Shared.Next(summaries.Length)]
|
||||
}).ToArray();
|
||||
}
|
||||
|
||||
private class WeatherForecast
|
||||
{
|
||||
public DateOnly Date { get; set; }
|
||||
public int TemperatureC { get; set; }
|
||||
public string? Summary { get; set; }
|
||||
public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,10 @@
|
||||
<Router AppAssembly="typeof(Program).Assembly" NotFoundPage="typeof(Pages.NotFound)">
|
||||
<Found Context="routeData">
|
||||
<RouteView RouteData="routeData" DefaultLayout="typeof(Layout.MainLayout)" />
|
||||
<AuthorizeRouteView RouteData="routeData" DefaultLayout="typeof(Layout.MainLayout)">
|
||||
<NotAuthorized>
|
||||
<RedirectToLogin />
|
||||
</NotAuthorized>
|
||||
</AuthorizeRouteView>
|
||||
<FocusOnNavigate RouteData="routeData" Selector="h1" />
|
||||
</Found>
|
||||
</Router>
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
@using System.Net.Http
|
||||
@using System.Net.Http.Json
|
||||
@using System.Text.Json
|
||||
@using Microsoft.AspNetCore.Components.Authorization
|
||||
@using Microsoft.AspNetCore.Components.Forms
|
||||
@using Microsoft.AspNetCore.Components.Routing
|
||||
@using Microsoft.AspNetCore.Components.Web
|
||||
@using static Microsoft.AspNetCore.Components.Web.RenderMode
|
||||
@using Microsoft.AspNetCore.Components.Web.Virtualization
|
||||
@using Microsoft.AspNetCore.Authorization
|
||||
@using Microsoft.JSInterop
|
||||
@using CannedNet.Admin
|
||||
@using CannedNet.Admin.Components
|
||||
@using CannedNet.Admin.Components.Layout
|
||||
@using CannedNet.Admin.Components.Pages
|
||||
@using CannedNet.Admin.Services
|
||||
@using CannedNet
|
||||
@using CannedNet.Models
|
||||
|
||||
Reference in New Issue
Block a user