basic admin panel stuff

This commit is contained in:
Lapis
2026-06-17 13:39:28 -04:00
parent c20d215ee0
commit 004f1921a6
44 changed files with 3432 additions and 315 deletions
+5 -2
View File
@@ -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.
+62 -16
View File
@@ -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 &gt; 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++;
}
}
+5 -11
View File
@@ -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;
}
+52 -2
View File
@@ -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);
}
}
+5 -1
View File
@@ -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