diff --git a/extra/admin-api/SpacebarAdminAPI.slnx b/extra/admin-api/SpacebarAdminAPI.slnx
index e2ec0b00..9a6115ee 100644
--- a/extra/admin-api/SpacebarAdminAPI.slnx
+++ b/extra/admin-api/SpacebarAdminAPI.slnx
@@ -1,42 +1,44 @@
<Solution>
<Configurations>
- <Platform Name="Any CPU"/>
- <Platform Name="x64"/>
- <Platform Name="x86"/>
+ <Platform Name="Any CPU" />
+ <Platform Name="x64" />
+ <Platform Name="x86" />
</Configurations>
<Folder Name="/DataMappings/">
- <Project Path="DataMappings/Spacebar.DataMappings.AdminApi/Spacebar.DataMappings.AdminApi.csproj"/>
- <Project Path="DataMappings/Spacebar.DataMappings.Generic/Spacebar.DataMappings.Generic.csproj"/>
+ <Project Path="DataMappings/Spacebar.DataMappings.AdminApi/Spacebar.DataMappings.AdminApi.csproj" />
+ <Project Path="DataMappings/Spacebar.DataMappings.Generic/Spacebar.DataMappings.Generic.csproj" />
</Folder>
<Folder Name="/Interop/">
- <Project Path="Interop/Spacebar.Interop.Authentication.AspNetCore/Spacebar.Interop.Authentication.AspNetCore.csproj"/>
- <Project Path="Interop/Spacebar.Interop.Authentication/Spacebar.Interop.Authentication.csproj"/>
- <Project Path="Interop/Spacebar.Interop.Cdn.Abstractions/Spacebar.Interop.Cdn.Abstractions.csproj"/>
- <Project Path="Interop/Spacebar.Interop.Cdn.Signing/Spacebar.Interop.Cdn.Signing.csproj"/>
- <Project Path="Interop/Spacebar.Interop.Replication.Abstractions/Spacebar.Interop.Replication.Abstractions.csproj"/>
- <Project Path="Interop/Spacebar.Interop.Replication.RabbitMq/Spacebar.Interop.Replication.RabbitMq.csproj"/>
- <Project Path="Interop/Spacebar.Interop.Replication.UnixSocket/Spacebar.Interop.Replication.UnixSocket.csproj"/>
+ <Project Path="Interop/Spacebar.Interop.Authentication.AspNetCore/Spacebar.Interop.Authentication.AspNetCore.csproj" />
+ <Project Path="Interop/Spacebar.Interop.Authentication/Spacebar.Interop.Authentication.csproj" />
+ <Project Path="Interop/Spacebar.Interop.Cdn.Abstractions/Spacebar.Interop.Cdn.Abstractions.csproj" />
+ <Project Path="Interop/Spacebar.Interop.Cdn.Signing/Spacebar.Interop.Cdn.Signing.csproj" />
+ <Project Path="Interop/Spacebar.Interop.Replication.Abstractions/Spacebar.Interop.Replication.Abstractions.csproj" />
+ <Project Path="Interop/Spacebar.Interop.Replication.RabbitMq/Spacebar.Interop.Replication.RabbitMq.csproj" />
+ <Project Path="Interop/Spacebar.Interop.Replication.UnixSocket/Spacebar.Interop.Replication.UnixSocket.csproj" />
</Folder>
<Folder Name="/Models/">
- <Project Path="Models/Spacebar.Models.AdminApi/Spacebar.Models.AdminApi.csproj"/>
- <Project Path="Models/Spacebar.Models.Config/Spacebar.Models.Config.csproj"/>
- <Project Path="Models/Spacebar.Models.Db/Spacebar.Models.Db.csproj"/>
- <Project Path="Models/Spacebar.Models.Gateway/Spacebar.Models.Gateway.csproj"/>
- <Project Path="Models/Spacebar.Models.Generic/Spacebar.Models.Generic.csproj"/>
+ <Project Path="Models/Spacebar.Models.AdminApi/Spacebar.Models.AdminApi.csproj" />
+ <Project Path="Models/Spacebar.Models.Api/Spacebar.Models.Api.csproj" />
+ <Project Path="Models/Spacebar.Models.Config/Spacebar.Models.Config.csproj" />
+ <Project Path="Models/Spacebar.Models.Db/Spacebar.Models.Db.csproj" />
+ <Project Path="Models/Spacebar.Models.Gateway/Spacebar.Models.Gateway.csproj" />
+ <Project Path="Models/Spacebar.Models.Generic/Spacebar.Models.Generic.csproj" />
</Folder>
<Folder Name="/Utilities/">
- <Project Path="Utilities/ConfigTest/ConfigTest.csproj"/>
- <Project Path="Utilities/DiscordEmojiConverter/DiscordEmojiConverter.csproj"/>
- <Project Path="Utilities/Spacebar.AdminApi.PrepareTestData/Spacebar.AdminApi.PrepareTestData.csproj"/>
- <Project Path="Utilities/Spacebar.AdminApi.TestClient/Spacebar.AdminApi.TestClient.csproj"/>
- <Project Path="Utilities/Spacebar.AdminApiTest/Spacebar.AdminApiTest.csproj"/>
- <Project Path="Utilities/Spacebar.Cdn.Fsck/Spacebar.Cdn.Fsck.csproj"/>
- <Project Path="Utilities/Spacebar.Cdn.Migration/Spacebar.Cdn.Migration.csproj"/>
- <Project Path="Utilities/Spacebar.CleanSettingsRows/Spacebar.CleanSettingsRows.csproj"/>
+ <Project Path="Utilities/ConfigTest/ConfigTest.csproj" />
+ <Project Path="Utilities/DiscordEmojiConverter/DiscordEmojiConverter.csproj" />
+ <Project Path="Utilities/Spacebar.AdminApi.PrepareTestData/Spacebar.AdminApi.PrepareTestData.csproj" />
+ <Project Path="Utilities/Spacebar.AdminApi.TestClient/Spacebar.AdminApi.TestClient.csproj" />
+ <Project Path="Utilities/Spacebar.AdminApiTest/Spacebar.AdminApiTest.csproj" />
+ <Project Path="Utilities/Spacebar.Cdn.Fsck/Spacebar.Cdn.Fsck.csproj" />
+ <Project Path="Utilities/Spacebar.Cdn.Migration/Spacebar.Cdn.Migration.csproj" />
+ <Project Path="Utilities/Spacebar.CleanSettingsRows/Spacebar.CleanSettingsRows.csproj" />
+ <Project Path="Utilities/Spacebar.Client/Spacebar.Client.csproj" />
</Folder>
- <Project Path="Spacebar.AdminApi/Spacebar.AdminApi.csproj"/>
- <Project Path="Spacebar.Cdn.Worker/Spacebar.Cdn.Worker.Q16-HDRI.x86_64.csproj" DisplayName="Spacebar.Cdn.Worker"/>
- <Project Path="Spacebar.Cdn/Spacebar.Cdn.csproj"/>
- <Project Path="Spacebar.Offload/Spacebar.Offload.csproj"/>
- <Project Path="Spacebar.UApi/Spacebar.UApi.csproj"/>
+ <Project Path="Spacebar.AdminApi/Spacebar.AdminApi.csproj" />
+ <Project Path="Spacebar.Cdn.Worker/Spacebar.Cdn.Worker.Q16-HDRI.x86_64.csproj" />
+ <Project Path="Spacebar.Cdn/Spacebar.Cdn.csproj" />
+ <Project Path="Spacebar.Offload/Spacebar.Offload.csproj" />
+ <Project Path="Spacebar.UApi/Spacebar.UApi.csproj" />
</Solution>
diff --git a/extra/admin-api/Utilities/Spacebar.Client/App.razor b/extra/admin-api/Utilities/Spacebar.Client/App.razor
new file mode 100644
index 00000000..9a3f1093
--- /dev/null
+++ b/extra/admin-api/Utilities/Spacebar.Client/App.razor
@@ -0,0 +1,18 @@
+@using Spacebar.Client.Components
+
+<SessionManager @ref="SessionManager"></SessionManager>
+<ClientManager @ref="ClientManager">
+ <Router AppAssembly="@typeof(App).Assembly" NotFoundPage="typeof(Pages.NotFound)">
+ <Found Context="routeData">
+ <RouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)"/>
+ <FocusOnNavigate RouteData="@routeData" Selector="h1"/>
+ </Found>
+ </Router>
+</ClientManager>
+
+
+@code {
+ public static SessionManager SessionManager { get; set; } = null!;
+ public static ClientManager ClientManager { get; set; } = null!;
+
+}
\ No newline at end of file
diff --git a/extra/admin-api/Utilities/Spacebar.Client/Components/ClientManager.razor b/extra/admin-api/Utilities/Spacebar.Client/Components/ClientManager.razor
new file mode 100644
index 00000000..71e82698
--- /dev/null
+++ b/extra/admin-api/Utilities/Spacebar.Client/Components/ClientManager.razor
@@ -0,0 +1,61 @@
+@using ArcaneLibs.Blazor.Components.Services
+@using ArcaneLibs.Extensions
+@using Spacebar.Client.Core
+@using Spacebar.Client.WebCore
+@using Spacebar.Models.Gateway
+@inject SessionStore sessionStore
+@inject SpacebarClientProviderService clientProvider
+@inject JsConsoleService jsConsole
+
+<DebugBanner Name="@GetType().Name" @ref="_dbgBanner"/>
+<CascadingValue TValue="AuthenticatedSpacebarClient" Value="@_client">
+ @ChildContent
+</CascadingValue>
+
+@code {
+ private DebugBanner _dbgBanner = null!;
+ private AuthenticatedSpacebarClient? _client { get; set; }
+
+ [Parameter]
+ public required RenderFragment ChildContent { get; set; }
+
+ protected override async Task OnAfterRenderAsync(bool firstRender) {
+ if (!firstRender) return;
+ await _dbgBanner.SetStatus("Preparing for launch!");
+ await Task.Delay(125);
+ var session = await sessionStore.GetCurrentSessionAsync();
+ if (session != null) {
+ _client = await clientProvider.GetAuthenticatedClientAsync(session.ServerName, session.AccessToken);
+ await _dbgBanner.SetStatus($"Got authenticated client for {session.ProfileCache.Username}#{session.ProfileCache.Discriminator} on {session.ServerName}! Connecting to gateway...");
+ _client.Gateway.IdentifyData.ClientProperties = new IdentifyClientProperties() {
+ HasClientMods = false,
+ ApplicationArchitecture = "wasm"
+ }.ToJsonNode().AsObject();
+ StateHasChanged();
+ await _client.Gateway.Connect();
+ _ = _client.Gateway.Start().ContinueWith(ct => {
+ jsConsole.Warn("[ClientManager] Heartbeat loop exited!");
+ if (ct.IsFaulted) {
+ jsConsole.Error("Unhandled exception during gateway connection:", ct.Exception.ToString());
+ throw ct.Exception;
+ }
+ });
+ _client.Gateway.OnceGatewayMessage.Add(async msg => {
+ if (msg is { Opcode: GatewayOpcode.S2CDispatch, DispatchEventType: "READY" }) {
+ await _dbgBanner.SetStatus($"Got READY from gateway");
+ await _dbgBanner.SetStatus(null, 1750);
+ var content = msg.GetData<ReadyResponse>();
+ await jsConsole.Info("Parsed ready payload:", content);
+ return true;
+ }
+ return false;
+ });
+ }
+ else {
+ await _dbgBanner.SetStatus("No session marked as current... :(");
+ await _dbgBanner.SetStatus(null, 1750);
+ }
+
+ }
+
+}
\ No newline at end of file
diff --git a/extra/admin-api/Utilities/Spacebar.Client/Components/DebugBanner.razor b/extra/admin-api/Utilities/Spacebar.Client/Components/DebugBanner.razor
new file mode 100644
index 00000000..4cc62781
--- /dev/null
+++ b/extra/admin-api/Utilities/Spacebar.Client/Components/DebugBanner.razor
@@ -0,0 +1,23 @@
+<span class="@("dbgBanner " + (_bannerVisible ? "" : "hidden"))"><b class="code">@(Name)</b>: @_status</span>
+
+@code {
+
+ private bool _bannerVisible = true;
+ private string? _status = "initializing...";
+
+ [Parameter]
+ public required string Name { get; set; }
+
+ public async Task SetStatus(string? message, int closeDelay = 0) {
+ _bannerVisible = !string.IsNullOrWhiteSpace(message);
+ if (!_bannerVisible) {
+ await Task.Delay(closeDelay);
+ StateHasChanged();
+ await Task.Delay(1000);
+ }
+
+ _status = message;
+ StateHasChanged();
+ await Task.Yield();
+ }
+}
\ No newline at end of file
diff --git a/extra/admin-api/Utilities/Spacebar.Client/Components/SessionManager.razor b/extra/admin-api/Utilities/Spacebar.Client/Components/SessionManager.razor
new file mode 100644
index 00000000..0c9a1064
--- /dev/null
+++ b/extra/admin-api/Utilities/Spacebar.Client/Components/SessionManager.razor
@@ -0,0 +1,52 @@
+@using ArcaneLibs.Blazor.Components
+@using ArcaneLibs.Blazor.Components.Services
+@using Spacebar.Client.WebCore
+@inject SessionStore sessionStore
+@inject JsConsoleService jsConsole
+
+<DebugBanner Name="@GetType().Name" @ref="_dbgBanner"/>
+@if (_sessionPickerVisible) {
+ <ModalWindow Title="Session manager" OnCloseClickedAsync="@(() => SetSessionPickerVisible(false))">
+ @foreach (var (sessionId, session) in _sessionEntries) {
+ <LinkButton
+ OnClickAsync="@(() => SetCurrentSessionAsync(sessionId))">@(session.ProfileCache?.Username ?? "Unknown user")#@(session.ProfileCache?.Discriminator ?? "0000") <sub
+ class="code">on @session.ServerName</sub></LinkButton>
+ }
+ <br/><br/>
+ <LinkButton href="/login">Log in</LinkButton>
+ <LinkButton href="/register">Register</LinkButton>
+ </ModalWindow>
+}
+
+@code {
+ private bool _sessionPickerVisible;
+ private DebugBanner _dbgBanner;
+
+ private Dictionary<Guid, SessionEntry> _sessionEntries = [];
+
+ protected override async Task OnAfterRenderAsync(bool firstRender) {
+ if (!firstRender) return;
+ await _dbgBanner.SetStatus("Crunching numbers...");
+ _sessionPickerVisible = await sessionStore.GetCurrentSessionAsync() == null;
+ await _dbgBanner.SetStatus("Ready!");
+ await _dbgBanner.SetStatus(null, 1000);
+ }
+
+ public async Task SetSessionPickerVisible(bool visible = true) {
+ await jsConsole.Info("[SessionManager] showing session picker...");
+ _sessionEntries = visible ? await sessionStore.GetAllSessionsAsync() : [];
+ _sessionPickerVisible = visible;
+ StateHasChanged();
+ await Task.Delay(1);
+ }
+
+ private async Task SetCurrentSessionAsync(Guid sessionId) {
+ _sessionPickerVisible = false;
+ StateHasChanged();
+ await sessionStore.SetCurrentSessionAsync(sessionId);
+ var cs = await sessionStore.GetCurrentSessionAsync();
+ await _dbgBanner.SetStatus($"Current session changed to {cs!.ProfileCache?.Username}#{cs.ProfileCache?.Discriminator} ({cs.ProfileCache.Id}) / {sessionId.ToString()}!");
+ await _dbgBanner.SetStatus(null, 3000);
+ }
+
+}
\ No newline at end of file
diff --git a/extra/admin-api/Utilities/Spacebar.Client/Core/SpacebarClient.cs b/extra/admin-api/Utilities/Spacebar.Client/Core/SpacebarClient.cs
new file mode 100644
index 00000000..f8d90309
--- /dev/null
+++ b/extra/admin-api/Utilities/Spacebar.Client/Core/SpacebarClient.cs
@@ -0,0 +1,164 @@
+using System.Data.Common;
+using System.Diagnostics;
+using System.Net.Http.Json;
+using System.Net.WebSockets;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+using ArcaneLibs.Extensions;
+using Spacebar.Models.Api;
+using Spacebar.Models.Gateway;
+using Spacebar.Models.Generic;
+
+namespace Spacebar.Client.Core;
+
+public class UnauthenticatedSpacebarClient(ILogger<UnauthenticatedSpacebarClient> logger, SpacebarClientWellKnown wellKnown) {
+ public async Task<LoginResponse> LoginAsync(LoginRequest request) {
+ // TODO: rebase
+ using var hc = new HttpClient();
+ var resp = await hc.PostAsJsonAsync(new Uri(wellKnown.Api.GetApiBaseUrl(), "auth/login"), request);
+ // TODO: abstract out
+ if (!resp.IsSuccessStatusCode) throw SpacebarApiException.FromJson((await resp.Content.ReadFromJsonAsync<JsonObject>())!);
+ return (await resp.Content.ReadFromJsonAsync<LoginResponse>())!;
+ }
+}
+
+public class AuthenticatedSpacebarClient {
+ private readonly ILogger<AuthenticatedSpacebarClient> _logger;
+
+ public AuthenticatedSpacebarClient(ILogger<AuthenticatedSpacebarClient> logger, IServiceProvider sp, SpacebarClientWellKnown wellKnown, string token) {
+ _logger = logger;
+ ApiHttpClient = new HttpClient() {
+ BaseAddress = wellKnown.Api.GetApiBaseUrl()
+ };
+ ApiHttpClient.DefaultRequestHeaders.Authorization = new("Bearer", token);
+ Gateway = new(sp.GetRequiredService<ILogger<AuthenticatedSpacebarGatewayClient>>(), wellKnown, token);
+ }
+
+ public HttpClient ApiHttpClient { get; set; }
+ public AuthenticatedSpacebarGatewayClient Gateway { get; set; }
+
+ // TODO: write a proper full user model...
+ public async Task<PartialUser> GetCurrentUser() {
+ var resp = await ApiHttpClient.GetAsync("users/@me");
+ // TODO: abstract out
+ if (!resp.IsSuccessStatusCode) throw SpacebarApiException.FromJson((await resp.Content.ReadFromJsonAsync<JsonObject>())!);
+ return (await resp.Content.ReadFromJsonAsync<PartialUser>())!;
+ }
+
+ ~AuthenticatedSpacebarClient() {
+ ApiHttpClient.Dispose();
+ }
+}
+
+public class AuthenticatedSpacebarGatewayClient(ILogger<AuthenticatedSpacebarGatewayClient> logger, SpacebarClientWellKnown wellKnown, string token) {
+ public ClientWebSocket RawClientWebSocket = new();
+ public int Sequence;
+ public bool TraceGatewayMessages = false;
+
+ public IdentifyRequest IdentifyData { get; } = new() {
+ Intents = (GatewayIntentFlags?)0xFFFFFFFF, // too lazy to do math, just gimme everything
+ Capabilities = 0
+ };
+
+ public List<Func<GatewayPayload, Task>> OnGatewayMessage { get; } = [];
+ public List<Func<GatewayPayload, Task<bool>>> OnceGatewayMessage { get; } = [];
+
+ public async Task Connect() {
+ if (RawClientWebSocket.State is WebSocketState.Connecting or WebSocketState.Open) return;
+ Sequence = 0;
+ await RawClientWebSocket.ConnectAsync(new Uri(wellKnown.Gateway.BaseUrl).AddQuery("encoding", "json"), CancellationToken.None);
+ }
+
+ public async Task Start() {
+ await foreach (var msg in _runReceiveLoop()) {
+ // logger.LogInformation("Got gateway message: {msg}", msg);
+ if (msg.Opcode == GatewayOpcode.S2CHello) {
+ _ = _runHeartbeatLoop(msg.GetData<HelloResponse>()!.HeartbeatInterval).ContinueWith(ct => {
+ logger.LogWarning("Heartbeat loop exited!");
+ if (ct.IsFaulted) throw ct.Exception;
+ });
+ IdentifyData.Token = token;
+ await RawClientWebSocket.SendAsync(JsonSerializer.SerializeToUtf8Bytes(new GatewayPayload() {
+ Opcode = GatewayOpcode.C2SIdentify, // Identify
+ EventData = IdentifyData.ToJsonNode().AsObject()
+ }), WebSocketMessageType.Text, WebSocketMessageFlags.EndOfMessage, CancellationToken.None);
+ }
+ else if (msg.Opcode == GatewayOpcode.S2CHeartbeatAck) {
+ logger.LogInformation("Got heartbeat ACK from server!");
+ }
+
+ await Task.WhenAll(OnGatewayMessage.Select(x => x(msg)).ToArray());
+ foreach (var t in OnceGatewayMessage.Select((Func<GatewayPayload, Task<bool>> Callback, Task<bool> WasHandled) (cb) => (cb, cb(msg))).ToList()) {
+ var handled = await t.WasHandled;
+ if (handled) {
+ OnceGatewayMessage.Remove(t.Callback);
+ }
+ }
+ }
+ }
+
+ private async Task _runHeartbeatLoop(int interval) {
+ while (RawClientWebSocket.State < WebSocketState.Closed) {
+ await RawClientWebSocket.SendAsync(JsonSerializer.SerializeToUtf8Bytes(new GatewayPayload() {
+ Opcode = GatewayOpcode.C2SQoSHeartbeat, // QoS Heartbeat
+ EventData = new QoSHeartbeatRequest() {
+ Sequence = Sequence,
+ QoSPayload = new() {
+ Active = true,
+ Reasons = ["foregrounded"],
+ Version = 27
+ }
+ }.ToJsonNode().AsObject()
+ }), WebSocketMessageType.Text, WebSocketMessageFlags.EndOfMessage, CancellationToken.None);
+ await Task.Delay(interval);
+ }
+ }
+
+ private const int ReceiveBufferSize = 2 * 1024 * 1024;
+
+ private async IAsyncEnumerable<GatewayPayload> _runReceiveLoop() {
+ List<byte> messageParts = [];
+ List<(string Name, TimeSpan Elapsed)> trace = [];
+ var buffer = new byte[ReceiveBufferSize];
+ int idx = 0;
+
+ while (RawClientWebSocket.State < WebSocketState.Closed) {
+ var sw = Stopwatch.StartNew();
+
+ var msg = await RawClientWebSocket.ReceiveAsync(buffer, CancellationToken.None);
+ trace.Add(($"RCV.{idx}", sw.GetElapsedAndRestart()));
+
+ Console.WriteLine($"Websocket message chunk read: {msg.MessageType} {msg.Count} {msg.EndOfMessage}");
+ messageParts.AddRange(msg.Count < ReceiveBufferSize ? buffer[..msg.Count] : buffer);
+ trace.Add(($"STO.{idx}({msg.Count})", sw.GetElapsedAndRestart()));
+ idx++;
+
+ if (msg.EndOfMessage) {
+ Console.WriteLine("Got message, deserialising...");
+ var fullMsg = messageParts.ToArray();
+ trace.Add(($"LD({messageParts.Count})", sw.GetElapsedAndRestart()));
+
+ var d = JsonSerializer.Deserialize<GatewayPayload>(fullMsg);
+ trace.Add(($"LJS({fullMsg.Length})", sw.GetElapsedAndRestart()));
+
+ yield return d ?? throw new InvalidDataException("Gateway message deserialisation returned null?");
+ trace.Add(($"YLD", sw.GetElapsedAndRestart()));
+
+ if (TraceGatewayMessages) {
+ Console.WriteLine("Yielded message!");
+ Console.WriteLine("Trace:");
+ foreach (var t in trace)
+ Console.WriteLine($" - {t.Name}: {t.Elapsed}");
+ }
+
+ messageParts.Clear();
+ trace.Clear();
+ trace.Add(($"RST", sw.GetElapsedAndRestart()));
+ }
+ }
+ }
+
+ ~AuthenticatedSpacebarGatewayClient() {
+ RawClientWebSocket.Dispose();
+ }
+}
\ No newline at end of file
diff --git a/extra/admin-api/Utilities/Spacebar.Client/Core/SpacebarClientProviderService.cs b/extra/admin-api/Utilities/Spacebar.Client/Core/SpacebarClientProviderService.cs
new file mode 100644
index 00000000..a732c9a5
--- /dev/null
+++ b/extra/admin-api/Utilities/Spacebar.Client/Core/SpacebarClientProviderService.cs
@@ -0,0 +1,26 @@
+using ArcaneLibs.Collections;
+
+namespace Spacebar.Client.Core;
+
+public class SpacebarClientProviderService(ILogger<SpacebarClientProviderService> logger, IServiceProvider serviceProvider, SpacebarClientWellKnownResolverService clientWellKnownResolver) {
+ private static readonly SemaphoreCache<UnauthenticatedSpacebarClient> UnauthenticatedClientCache = new();
+ private static readonly SemaphoreCache<AuthenticatedSpacebarClient> AuthenticatedClientCache = new();
+
+ public async Task<UnauthenticatedSpacebarClient> GetUnauthenticatedClientAsync(string serverName) {
+ return await UnauthenticatedClientCache.GetOrAdd(serverName, async () => {
+ logger.LogInformation("Creating a new unauthenticated client for {serverName}!", serverName);
+ var clientLogger = serviceProvider.GetRequiredService<ILogger<UnauthenticatedSpacebarClient>>();
+ var wellKnown = await clientWellKnownResolver.ResolveClientWellKnown(serverName);
+ return new UnauthenticatedSpacebarClient(clientLogger, wellKnown);
+ });
+ }
+
+ public async Task<AuthenticatedSpacebarClient> GetAuthenticatedClientAsync(string serverName, string accessToken) {
+ return await AuthenticatedClientCache.GetOrAdd(serverName, async () => {
+ logger.LogInformation("Creating a new authenticated client for {serverName}!", serverName);
+ var clientLogger = serviceProvider.GetRequiredService<ILogger<AuthenticatedSpacebarClient>>();
+ var wellKnown = await clientWellKnownResolver.ResolveClientWellKnown(serverName);
+ return new AuthenticatedSpacebarClient(clientLogger, serviceProvider, wellKnown, accessToken);
+ });
+ }
+}
\ No newline at end of file
diff --git a/extra/admin-api/Utilities/Spacebar.Client/Core/SpacebarClientWellKnownResolver.cs b/extra/admin-api/Utilities/Spacebar.Client/Core/SpacebarClientWellKnownResolver.cs
new file mode 100644
index 00000000..a106435b
--- /dev/null
+++ b/extra/admin-api/Utilities/Spacebar.Client/Core/SpacebarClientWellKnownResolver.cs
@@ -0,0 +1,57 @@
+using System.Net.Http.Json;
+using System.Text.Json.Serialization;
+
+namespace Spacebar.Client.Core;
+
+public class SpacebarClientWellKnownResolverService(ILogger<SpacebarClientWellKnownResolverService> logger) {
+ public async Task<SpacebarClientWellKnown> ResolveClientWellKnown(string serverName) {
+ using var hc = new HttpClient();
+ logger.LogInformation("Resolving .well-known for {serverName}", serverName);
+ return await hc.GetFromJsonAsync<SpacebarClientWellKnown>($"https://{serverName}/.well-known/spacebar/client")!;
+ }
+}
+
+public class SpacebarClientWellKnown {
+ [JsonPropertyName("api")]
+ public required ApiWellKnownData Api { get; set; }
+
+ [JsonPropertyName("cdn")]
+ public required GenericUrlWellKnownData Cdn { get; set; }
+
+ [JsonPropertyName("admin")]
+ public GenericUrlWellKnownData? Admin { get; set; }
+
+ [JsonPropertyName("gateway")]
+ public required GatewayWellKnownData Gateway { get; set; }
+
+ public class GenericUrlWellKnownData {
+ [JsonPropertyName("baseUrl")]
+ public required string BaseUrl { get; set; }
+ }
+
+ public class ApiWellKnownData : GenericUrlWellKnownData {
+ [JsonPropertyName("apiVersions")]
+ public required ApiVersionsData ApiVersions { get; set; }
+
+ // Utility methods
+ public Uri GetApiBaseUrl(string? version = null) {
+ return new Uri(BaseUrl + "/api/v" + (version ?? ApiVersions.Default));
+ }
+
+ public class ApiVersionsData {
+ [JsonPropertyName("default")]
+ public required string Default { get; set; }
+
+ [JsonPropertyName("active")]
+ public required List<string> Active { get; set; }
+ }
+ }
+
+ public class GatewayWellKnownData : GenericUrlWellKnownData {
+ [JsonPropertyName("encoding")]
+ public required List<string> Encoding { get; set; }
+
+ [JsonPropertyName("compression")]
+ public required List<string?> Compression { get; set; }
+ }
+}
\ No newline at end of file
diff --git a/extra/admin-api/Utilities/Spacebar.Client/Layout/MainLayout.razor b/extra/admin-api/Utilities/Spacebar.Client/Layout/MainLayout.razor
new file mode 100644
index 00000000..e7554be8
--- /dev/null
+++ b/extra/admin-api/Utilities/Spacebar.Client/Layout/MainLayout.razor
@@ -0,0 +1,16 @@
+@inherits LayoutComponentBase
+<div class="page">
+ <div class="sidebar">
+ <NavMenu/>
+ </div>
+
+ <main>
+ <div class="top-row px-4">
+ <a href="https://learn.microsoft.com/aspnet/core/" target="_blank">About</a>
+ </div>
+
+ <article class="content px-4">
+ @Body
+ </article>
+ </main>
+</div>
\ No newline at end of file
diff --git a/extra/admin-api/Utilities/Spacebar.Client/Layout/MainLayout.razor.css b/extra/admin-api/Utilities/Spacebar.Client/Layout/MainLayout.razor.css
new file mode 100644
index 00000000..ecf25e5b
--- /dev/null
+++ b/extra/admin-api/Utilities/Spacebar.Client/Layout/MainLayout.razor.css
@@ -0,0 +1,77 @@
+.page {
+ position: relative;
+ display: flex;
+ flex-direction: column;
+}
+
+main {
+ flex: 1;
+}
+
+.sidebar {
+ background-image: linear-gradient(180deg, rgb(5, 39, 103) 0%, #3a0647 70%);
+}
+
+.top-row {
+ background-color: #f7f7f7;
+ border-bottom: 1px solid #d6d5d5;
+ justify-content: flex-end;
+ height: 3.5rem;
+ display: flex;
+ align-items: center;
+}
+
+ .top-row ::deep a, .top-row ::deep .btn-link {
+ white-space: nowrap;
+ margin-left: 1.5rem;
+ text-decoration: none;
+ }
+
+ .top-row ::deep a:hover, .top-row ::deep .btn-link:hover {
+ text-decoration: underline;
+ }
+
+ .top-row ::deep a:first-child {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+
+@media (max-width: 640.98px) {
+ .top-row {
+ justify-content: space-between;
+ }
+
+ .top-row ::deep a, .top-row ::deep .btn-link {
+ margin-left: 0;
+ }
+}
+
+@media (min-width: 641px) {
+ .page {
+ flex-direction: row;
+ }
+
+ .sidebar {
+ width: 250px;
+ height: 100vh;
+ position: sticky;
+ top: 0;
+ }
+
+ .top-row {
+ position: sticky;
+ top: 0;
+ z-index: 1;
+ }
+
+ .top-row.auth ::deep a:first-child {
+ flex: 1;
+ text-align: right;
+ width: 0;
+ }
+
+ .top-row, article {
+ padding-left: 2rem !important;
+ padding-right: 1.5rem !important;
+ }
+}
diff --git a/extra/admin-api/Utilities/Spacebar.Client/Layout/NavMenu.razor b/extra/admin-api/Utilities/Spacebar.Client/Layout/NavMenu.razor
new file mode 100644
index 00000000..ccc73abe
--- /dev/null
+++ b/extra/admin-api/Utilities/Spacebar.Client/Layout/NavMenu.razor
@@ -0,0 +1,39 @@
+<div class="top-row ps-3 navbar navbar-dark">
+ <div class="container-fluid">
+ <a class="navbar-brand" href="">Spacebar.Client</a>
+ <button title="Navigation menu" class="navbar-toggler" @onclick="ToggleNavMenu">
+ <span class="navbar-toggler-icon"></span>
+ </button>
+ </div>
+</div>
+
+<div class="@NavMenuCssClass nav-scrollable" @onclick="ToggleNavMenu">
+ <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>
+ <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-item px-3">
+ <NavLink class="nav-link" href="weather">
+ <span class="bi bi-list-nested-nav-menu" aria-hidden="true"></span> Weather
+ </NavLink>
+ </div>
+ </nav>
+</div>
+
+@code {
+ private bool collapseNavMenu = true;
+
+ private string? NavMenuCssClass => collapseNavMenu ? "collapse" : null;
+
+ private void ToggleNavMenu() {
+ collapseNavMenu = !collapseNavMenu;
+ }
+
+}
\ No newline at end of file
diff --git a/extra/admin-api/Utilities/Spacebar.Client/Layout/NavMenu.razor.css b/extra/admin-api/Utilities/Spacebar.Client/Layout/NavMenu.razor.css
new file mode 100644
index 00000000..617b89cc
--- /dev/null
+++ b/extra/admin-api/Utilities/Spacebar.Client/Layout/NavMenu.razor.css
@@ -0,0 +1,83 @@
+.navbar-toggler {
+ background-color: rgba(255, 255, 255, 0.1);
+}
+
+.top-row {
+ min-height: 3.5rem;
+ background-color: rgba(0,0,0,0.4);
+}
+
+.navbar-brand {
+ font-size: 1.1rem;
+}
+
+.bi {
+ display: inline-block;
+ position: relative;
+ width: 1.25rem;
+ height: 1.25rem;
+ margin-right: 0.75rem;
+ top: -1px;
+ 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;
+}
+
+ .nav-item:first-of-type {
+ padding-top: 1rem;
+ }
+
+ .nav-item:last-of-type {
+ padding-bottom: 1rem;
+ }
+
+ .nav-item ::deep a {
+ color: #d7d7d7;
+ border-radius: 4px;
+ height: 3rem;
+ display: flex;
+ align-items: center;
+ line-height: 3rem;
+ }
+
+.nav-item ::deep a.active {
+ background-color: rgba(255,255,255,0.37);
+ color: white;
+}
+
+.nav-item ::deep a:hover {
+ background-color: rgba(255,255,255,0.1);
+ color: white;
+}
+
+@media (min-width: 641px) {
+ .navbar-toggler {
+ display: none;
+ }
+
+ .collapse {
+ /* Never collapse the sidebar for wide screens */
+ display: block;
+ }
+
+ .nav-scrollable {
+ /* Allow sidebar to scroll for tall menus */
+ height: calc(100vh - 3.5rem);
+ overflow-y: auto;
+ }
+}
diff --git a/extra/admin-api/Utilities/Spacebar.Client/Pages/Auth/Login.razor b/extra/admin-api/Utilities/Spacebar.Client/Pages/Auth/Login.razor
new file mode 100644
index 00000000..a7ab1828
--- /dev/null
+++ b/extra/admin-api/Utilities/Spacebar.Client/Pages/Auth/Login.razor
@@ -0,0 +1,92 @@
+@page "/login"
+@using ArcaneLibs.Blazor.Components
+@using ArcaneLibs.Extensions
+@using Spacebar.Client.Core
+@using Spacebar.Client.WebCore
+@inject SpacebarClientWellKnownResolverService clientWellKnownResolver
+@inject SessionStore sessionStore
+@inject SpacebarClientProviderService clientProvider
+
+<PageTitle>Log in</PageTitle>
+
+<span>Instance (server name):</span>
+<FancyTextBox @bind-Value="@ServerName"/>
+<LinkButton InlineText="true">[🧭 Discover]</LinkButton>
+<br/>
+<span>Email:</span>
+<FancyTextBox @bind-Value="@Username"/>
+<br/>
+<span>Password:</span>
+<FancyTextBox @bind-Value="@Password" IsPassword="true"/>
+<br/>
+<LinkButton OnClickAsync="LoginAsync">Log in</LinkButton>
+
+<pre>@ServerValidationStatus</pre>
+
+@code{
+
+ private string ServerName {
+ get;
+ set {
+ field = value;
+ CheckServer(value);
+ }
+ } = "spacebar.chat";
+
+ private string Username { get; set; } = "";
+ private string Password { get; set; } = "";
+
+ private string? ServerValidationStatus;
+
+ private async Task SetStatus(string? status) {
+ ServerValidationStatus = status;
+ StateHasChanged();
+ await Task.Yield();
+ }
+
+ private async Task CheckServer(string serverName) {
+ await SetStatus("Checking server: " + serverName);
+ try {
+ await clientWellKnownResolver.ResolveClientWellKnown(serverName);
+ await SetStatus($"Server {serverName} is valid!");
+ }
+ catch (Exception e) {
+ await SetStatus("Could not validate server: " + e.Message);
+ }
+ }
+
+ private async Task LoginAsync() {
+ try {
+ await SetStatus($"Looking up {ServerName}...");
+ var cwk = await clientWellKnownResolver.ResolveClientWellKnown(ServerName);
+
+ await SetStatus($"Trying to log in to {ServerName}...");
+ var usc = await clientProvider.GetUnauthenticatedClientAsync(ServerName);
+ var lrsp = await usc.LoginAsync(new() {
+ Login = Username,
+ Password = Password
+ });
+
+ await SetStatus($"Logged in as user ID {lrsp.UserId}, fetching profile...");
+ var asc = await clientProvider.GetAuthenticatedClientAsync(ServerName, lrsp.Token!);
+ var cu = await asc.GetCurrentUser();
+ await SetStatus($"Logged in as {cu.Username}#{cu.Discriminator} ({cu.Id})! Saving...");
+ await sessionStore.AddSession(new SessionEntry() {
+ ServerName = ServerName,
+ AccessToken = lrsp.Token!,
+ ProfileCache = new() {
+ Id = cu.Id,
+ Username = cu.Username,
+ Discriminator = cu.Discriminator,
+ AvatarUrl = cu.Avatar ?? "",
+ }
+ }, setCurrent: true);
+
+ // await SetStatus(lrsp.ToJson());
+ }
+ catch (Exception e) {
+ await SetStatus("Failed to log in: " + e);
+ }
+ }
+
+}
\ No newline at end of file
diff --git a/extra/admin-api/Utilities/Spacebar.Client/Pages/Channels/@me.razor b/extra/admin-api/Utilities/Spacebar.Client/Pages/Channels/@me.razor
new file mode 100644
index 00000000..28709841
--- /dev/null
+++ b/extra/admin-api/Utilities/Spacebar.Client/Pages/Channels/@me.razor
@@ -0,0 +1,28 @@
+@* Workaround for Rider "Bad compile constant value" bug *@
+@attribute [Route(PageUri)]
+@using ArcaneLibs.Blazor.Components.Services
+@using Spacebar.Client.Core
+@using Spacebar.Models.Gateway
+@inject JsConsoleService jsConsole
+
+<h3>@@me</h3>
+<p>@Client?.ApiHttpClient.BaseAddress</p>
+<p>@Client?.Gateway.RawClientWebSocket.State</p>
+
+@code {
+ private const string PageUri = "/channels/@me";
+
+ [Parameter, CascadingParameter]
+ public required AuthenticatedSpacebarClient? Client {
+ get;
+ set { field = value; Console.WriteLine("Set client: " + value); }
+ }
+
+ protected override async Task OnParametersSetAsync() {
+ if (Client == null) return;
+ Client.Gateway.OnGatewayMessage.Add(async msg => {
+ await jsConsole.Log("Received gateway message: ", msg);
+ });
+ }
+
+}
\ No newline at end of file
diff --git a/extra/admin-api/Utilities/Spacebar.Client/Pages/Home.razor b/extra/admin-api/Utilities/Spacebar.Client/Pages/Home.razor
new file mode 100644
index 00000000..fd39d3fc
--- /dev/null
+++ b/extra/admin-api/Utilities/Spacebar.Client/Pages/Home.razor
@@ -0,0 +1,26 @@
+@page "/"
+@page "/app"
+@using ArcaneLibs.Blazor.Components
+@using ArcaneLibs.Extensions
+@using Spacebar.Client.Core
+@using Spacebar.Client.WebCore
+@inject SpacebarClientWellKnownResolverService cswkrs
+@inject SessionStore sessionStore
+
+<PageTitle>Home</PageTitle>
+
+<h1>Hello, world!</h1>
+<LinkButton OnClickAsync="@(()=>App.SessionManager.SetSessionPickerVisible())">Trigger session picker</LinkButton>
+<pre>@Ss.ToJson()</pre>
+<pre>@Res.ToJson()</pre>
+
+@code{
+ private SpacebarClientWellKnown? Res { get; set; }
+ private SessionEntry? Ss { get; set; }
+
+ protected override async Task OnInitializedAsync() {
+ Ss = await sessionStore.GetCurrentSessionAsync();
+ Res = await cswkrs.ResolveClientWellKnown(Ss?.ServerName ?? "spacebar.chat");
+ }
+
+}
\ No newline at end of file
diff --git a/extra/admin-api/Utilities/Spacebar.Client/Pages/NotFound.razor b/extra/admin-api/Utilities/Spacebar.Client/Pages/NotFound.razor
new file mode 100644
index 00000000..917ada1d
--- /dev/null
+++ b/extra/admin-api/Utilities/Spacebar.Client/Pages/NotFound.razor
@@ -0,0 +1,5 @@
+@page "/not-found"
+@layout MainLayout
+
+<h3>Not Found</h3>
+<p>Sorry, the content you are looking for does not exist.</p>
\ No newline at end of file
diff --git a/extra/admin-api/Utilities/Spacebar.Client/Program.cs b/extra/admin-api/Utilities/Spacebar.Client/Program.cs
new file mode 100644
index 00000000..7d7d88e1
--- /dev/null
+++ b/extra/admin-api/Utilities/Spacebar.Client/Program.cs
@@ -0,0 +1,19 @@
+using ArcaneLibs.Blazor.Components.Services;
+using Microsoft.AspNetCore.Components.Web;
+using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
+using Spacebar.Client;
+using Spacebar.Client.Core;
+using Spacebar.Client.WebCore;
+
+var builder = WebAssemblyHostBuilder.CreateDefault(args);
+builder.RootComponents.Add<App>("#app");
+builder.RootComponents.Add<HeadOutlet>("head::after");
+
+builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) });
+builder.Services.AddSingleton<LocalStorageService>();
+builder.Services.AddSingleton<JsConsoleService>();
+builder.Services.AddSingleton<SessionStore>();
+builder.Services.AddSingleton<SpacebarClientProviderService>();
+builder.Services.AddSingleton<SpacebarClientWellKnownResolverService>();
+
+await builder.Build().RunAsync();
\ No newline at end of file
diff --git a/extra/admin-api/Utilities/Spacebar.Client/Properties/launchSettings.json b/extra/admin-api/Utilities/Spacebar.Client/Properties/launchSettings.json
new file mode 100644
index 00000000..77aa8afd
--- /dev/null
+++ b/extra/admin-api/Utilities/Spacebar.Client/Properties/launchSettings.json
@@ -0,0 +1,25 @@
+{
+ "$schema": "https://json.schemastore.org/launchsettings.json",
+ "profiles": {
+ "http": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": true,
+ "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}",
+ "applicationUrl": "http://localhost:5086",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ },
+ "https": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": true,
+ "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}",
+ "applicationUrl": "https://localhost:7015;http://localhost:5086",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ }
+ }
+}
diff --git a/extra/admin-api/Utilities/Spacebar.Client/Spacebar.Client.csproj b/extra/admin-api/Utilities/Spacebar.Client/Spacebar.Client.csproj
new file mode 100644
index 00000000..aae396d1
--- /dev/null
+++ b/extra/admin-api/Utilities/Spacebar.Client/Spacebar.Client.csproj
@@ -0,0 +1,39 @@
+<Project Sdk="Microsoft.NET.Sdk.BlazorWebAssembly">
+
+ <PropertyGroup>
+ <TargetFramework>net10.0</TargetFramework>
+ <Nullable>enable</Nullable>
+ <ImplicitUsings>enable</ImplicitUsings>
+ <OverrideHtmlAssetPlaceholders>true</OverrideHtmlAssetPlaceholders>
+ <ServiceWorkerAssetsManifest>service-worker-assets.js</ServiceWorkerAssetsManifest>
+
+ <!-- copied from https://cgit.rory.gay/matrix/tools/MatrixUtils.git/tree/MatrixUtils.Web/MatrixUtils.Web.csproj -->
+ <LinkIncremental>true</LinkIncremental>
+ <EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles>
+ <UseBlazorWebAssembly>true</UseBlazorWebAssembly>
+ <BlazorEnableCompression>false</BlazorEnableCompression>
+ <CompressionEnabled>false</CompressionEnabled>
+ <BlazorCacheBootResources>false</BlazorCacheBootResources>
+ <BlazorEnableTimeZoneSupport>false</BlazorEnableTimeZoneSupport>
+ <WasmEnableHotReload>false</WasmEnableHotReload>
+ <DebugSymbols>true</DebugSymbols>
+ </PropertyGroup>
+
+ <ItemGroup>
+ <PackageReference Include="ArcaneLibs.Blazor.Components" Version="1.0.1-preview.20260505-124817"/>
+ <PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="10.0.5"/>
+ <PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="10.0.5" PrivateAssets="all"/>
+ </ItemGroup>
+
+ <ItemGroup>
+ <ServiceWorker Include="wwwroot\service-worker.js" PublishedContent="wwwroot\service-worker.published.js"/>
+ </ItemGroup>
+
+ <ItemGroup>
+ <ProjectReference Include="..\..\Models\Spacebar.Models.AdminApi\Spacebar.Models.AdminApi.csproj"/>
+ <ProjectReference Include="..\..\Models\Spacebar.Models.Api\Spacebar.Models.Api.csproj"/>
+ <ProjectReference Include="..\..\Models\Spacebar.Models.Gateway\Spacebar.Models.Gateway.csproj"/>
+ <ProjectReference Include="..\..\Models\Spacebar.Models.Generic\Spacebar.Models.Generic.csproj"/>
+ </ItemGroup>
+
+</Project>
diff --git a/extra/admin-api/Utilities/Spacebar.Client/WebCore/SessionStore.cs b/extra/admin-api/Utilities/Spacebar.Client/WebCore/SessionStore.cs
new file mode 100644
index 00000000..ae98ee57
--- /dev/null
+++ b/extra/admin-api/Utilities/Spacebar.Client/WebCore/SessionStore.cs
@@ -0,0 +1,61 @@
+using System.Text.Json.Serialization;
+using ArcaneLibs.Blazor.Components.Services;
+
+namespace Spacebar.Client.WebCore;
+
+public class SessionStore(ILogger<SessionStore> logger, LocalStorageService localStorage) {
+ public const string CurrentSessionKey = "chat.spacebar.client.current_session";
+ public const string AllSessionsKey = "chat.spacebar.client.sessions";
+ public async Task<SessionEntry?> GetCurrentSessionAsync() {
+ if (!await localStorage.ContainsKeyAsync(CurrentSessionKey)) return null;
+ var entryId = await localStorage.GetItemFromJsonAsync<Guid>(CurrentSessionKey);
+ var entries = await GetAllSessionsAsync();
+ return entries[entryId];
+ }
+
+ public async Task<Dictionary<Guid, SessionEntry>> GetAllSessionsAsync() {
+ if (!await localStorage.ContainsKeyAsync(AllSessionsKey)) return [];
+ var data = await localStorage.GetItemFromJsonAsync<Dictionary<Guid, SessionEntry>>(AllSessionsKey);
+ return data ?? [];
+ }
+
+ public async Task AddSession(SessionEntry sessionEntry, bool setCurrent = false) {
+ var sessions = await GetAllSessionsAsync();
+ var newId = Guid.NewGuid();
+ sessions.Add(newId, sessionEntry);
+ await localStorage.SetItemAsJsonAsync(AllSessionsKey, sessions);
+
+ if (setCurrent) {
+ await localStorage.SetItemAsJsonAsync(CurrentSessionKey, newId);
+ }
+ }
+
+ public async Task SetCurrentSessionAsync(Guid sessionId) {
+ await localStorage.SetItemAsJsonAsync(CurrentSessionKey, sessionId);
+ }
+}
+
+public class SessionEntry {
+ [JsonPropertyName("server_name")]
+ public required string ServerName { get; set; }
+
+ [JsonPropertyName("access_token")]
+ public required string AccessToken { get; set; }
+
+ [JsonPropertyName("profile_cache")]
+ public required ProfileCacheData? ProfileCache { get; set; }
+
+ public class ProfileCacheData {
+ [JsonPropertyName("id"), JsonNumberHandling(JsonNumberHandling.AllowReadingFromString | JsonNumberHandling.WriteAsString)]
+ public required long Id { get; set; }
+
+ [JsonPropertyName("username")]
+ public required string Username { get; set; }
+
+ [JsonPropertyName("discriminator")]
+ public string? Discriminator { get; set; }
+
+ [JsonPropertyName("avatar_url")]
+ public required string AvatarUrl { get; set; }
+ }
+}
\ No newline at end of file
diff --git a/extra/admin-api/Utilities/Spacebar.Client/_Imports.razor b/extra/admin-api/Utilities/Spacebar.Client/_Imports.razor
new file mode 100644
index 00000000..55108164
--- /dev/null
+++ b/extra/admin-api/Utilities/Spacebar.Client/_Imports.razor
@@ -0,0 +1,10 @@
+@using System.Net.Http
+@using System.Net.Http.Json
+@using Microsoft.AspNetCore.Components.Forms
+@using Microsoft.AspNetCore.Components.Routing
+@using Microsoft.AspNetCore.Components.Web
+@using Microsoft.AspNetCore.Components.Web.Virtualization
+@using Microsoft.AspNetCore.Components.WebAssembly.Http
+@using Microsoft.JSInterop
+@using Spacebar.Client
+@using Spacebar.Client.Layout
\ No newline at end of file
diff --git a/extra/admin-api/Utilities/Spacebar.Client/wwwroot/css/app.css b/extra/admin-api/Utilities/Spacebar.Client/wwwroot/css/app.css
new file mode 100644
index 00000000..7c6e626e
--- /dev/null
+++ b/extra/admin-api/Utilities/Spacebar.Client/wwwroot/css/app.css
@@ -0,0 +1,171 @@
+@import url('jetbrains-mono/jetbrains-mono.css');
+
+html, body {
+ font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
+ background-color: #222;
+ color: #aaa;
+}
+
+pre, .code {
+ font-family: 'JetBrains Mono', monospace;
+}
+
+.dbgBanner {
+ transition: 0.25s;
+ display: block;
+ width: 100%;
+ transform: scale(1, 1);
+ height: 1.5em;
+}
+
+ .dbgBanner.hidden {
+ transition: 0.5s;
+ transform: scale(1, 0);
+ height: 0;
+ }
+
+#app > div > main > div {
+ background-color: #333;
+ border-bottom: none;
+}
+
+.table, .table-striped > tbody > tr:nth-of-type(odd), .table-hover > tbody > tr:hover {
+ color: unset;
+}
+
+h1:focus {
+ outline: none;
+}
+
+a, .btn-link {
+ color: #0071c1;
+}
+
+.btn-primary {
+ color: #fff;
+ background-color: #1b6ec2;
+ border-color: #1861ac;
+}
+
+.btn:focus, .btn:active:focus, .btn-link.nav-link:focus, .form-control:focus, .form-check-input:focus {
+ box-shadow: 0 0 0 0.1rem white, 0 0 0 0.25rem #258cfb;
+}
+
+.content {
+ padding-top: 1.1rem;
+}
+
+.valid.modified:not([type=checkbox]) {
+ outline: 1px solid #26b050;
+}
+
+.invalid {
+ outline: 1px solid red;
+}
+
+.validation-message {
+ color: red;
+}
+
+#blazor-error-ui {
+ color-scheme: light only;
+ background: lightyellow;
+ bottom: 0;
+ box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2);
+ box-sizing: border-box;
+ display: none;
+ left: 0;
+ padding: 0.6rem 1.25rem 0.7rem 1.25rem;
+ position: fixed;
+ width: 100%;
+ z-index: 1000;
+}
+
+ #blazor-error-ui .dismiss {
+ cursor: pointer;
+ position: absolute;
+ right: 0.75rem;
+ top: 0.5rem;
+ }
+
+.blazor-error-boundary {
+ background: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTYiIGhlaWdodD0iNDkiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIG92ZXJmbG93PSJoaWRkZW4iPjxkZWZzPjxjbGlwUGF0aCBpZD0iY2xpcDAiPjxyZWN0IHg9IjIzNSIgeT0iNTEiIHdpZHRoPSI1NiIgaGVpZ2h0PSI0OSIvPjwvY2xpcFBhdGg+PC9kZWZzPjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMCkiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0yMzUgLTUxKSI+PHBhdGggZD0iTTI2My41MDYgNTFDMjY0LjcxNyA1MSAyNjUuODEzIDUxLjQ4MzcgMjY2LjYwNiA1Mi4yNjU4TDI2Ny4wNTIgNTIuNzk4NyAyNjcuNTM5IDUzLjYyODMgMjkwLjE4NSA5Mi4xODMxIDI5MC41NDUgOTIuNzk1IDI5MC42NTYgOTIuOTk2QzI5MC44NzcgOTMuNTEzIDI5MSA5NC4wODE1IDI5MSA5NC42NzgyIDI5MSA5Ny4wNjUxIDI4OS4wMzggOTkgMjg2LjYxNyA5OUwyNDAuMzgzIDk5QzIzNy45NjMgOTkgMjM2IDk3LjA2NTEgMjM2IDk0LjY3ODIgMjM2IDk0LjM3OTkgMjM2LjAzMSA5NC4wODg2IDIzNi4wODkgOTMuODA3MkwyMzYuMzM4IDkzLjAxNjIgMjM2Ljg1OCA5Mi4xMzE0IDI1OS40NzMgNTMuNjI5NCAyNTkuOTYxIDUyLjc5ODUgMjYwLjQwNyA1Mi4yNjU4QzI2MS4yIDUxLjQ4MzcgMjYyLjI5NiA1MSAyNjMuNTA2IDUxWk0yNjMuNTg2IDY2LjAxODNDMjYwLjczNyA2Ni4wMTgzIDI1OS4zMTMgNjcuMTI0NSAyNTkuMzEzIDY5LjMzNyAyNTkuMzEzIDY5LjYxMDIgMjU5LjMzMiA2OS44NjA4IDI1OS4zNzEgNzAuMDg4N0wyNjEuNzk1IDg0LjAxNjEgMjY1LjM4IDg0LjAxNjEgMjY3LjgyMSA2OS43NDc1QzI2Ny44NiA2OS43MzA5IDI2Ny44NzkgNjkuNTg3NyAyNjcuODc5IDY5LjMxNzkgMjY3Ljg3OSA2Ny4xMTgyIDI2Ni40NDggNjYuMDE4MyAyNjMuNTg2IDY2LjAxODNaTTI2My41NzYgODYuMDU0N0MyNjEuMDQ5IDg2LjA1NDcgMjU5Ljc4NiA4Ny4zMDA1IDI1OS43ODYgODkuNzkyMSAyNTkuNzg2IDkyLjI4MzcgMjYxLjA0OSA5My41Mjk1IDI2My41NzYgOTMuNTI5NSAyNjYuMTE2IDkzLjUyOTUgMjY3LjM4NyA5Mi4yODM3IDI2Ny4zODcgODkuNzkyMSAyNjcuMzg3IDg3LjMwMDUgMjY2LjExNiA4Ni4wNTQ3IDI2My41NzYgODYuMDU0N1oiIGZpbGw9IiNGRkU1MDAiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPjwvZz48L3N2Zz4=) no-repeat 1rem/1.8rem, #b32121;
+ padding: 1rem 1rem 1rem 3.7rem;
+ color: white;
+}
+
+ .blazor-error-boundary::after {
+ content: "An error has occurred."
+ }
+
+.loading-progress {
+ position: absolute;
+ display: block;
+ width: 8rem;
+ height: 8rem;
+ inset: 20vh 0 auto 0;
+ margin: 0 auto 0 auto;
+}
+
+ .loading-progress circle {
+ fill: none;
+ stroke: #e0e0e0;
+ stroke-width: 0.6rem;
+ transform-origin: 50% 50%;
+ transform: rotate(-90deg);
+ }
+
+ .loading-progress circle:nth-child(2) {
+ stroke: #1b6ec2;
+ stroke-dasharray: calc(3.141 * var(--blazor-load-percentage, 0%) * 0.8), 500%;
+ transition: stroke-dasharray 0.05s ease-in-out;
+ }
+
+ .loading-progress foreignObject {
+ width: 100%;
+ height: 100%;
+ }
+
+ .loading-progress foreignObject>img {
+ width: 50%;
+ height: 50%;
+ position: absolute;
+ object-fit: contain;
+ object-position: center;
+ left: calc(25%);
+ top: calc(25%);
+ }
+
+ .loading-progress foreignObject>img:nth-of-type(1) {
+ opacity: calc(100% - var(--blazor-load-percentage, 0%));
+ transition: 0.5s;
+ }
+
+ .loading-progress foreignObject>img:nth-of-type(2) {
+ opacity: var(--blazor-load-percentage, 0%);
+ transition: 0.5s;
+ }
+
+.loading-progress-text {
+ position: absolute;
+ text-align: center;
+ font-weight: bold;
+ inset: calc(30vh + 3.25rem) 0 auto 0.2rem;
+}
+
+ .loading-progress-text:after {
+ content: var(--blazor-load-percentage-text, "Loading");
+ }
+
+code {
+ color: #c02d76;
+}
+
+.form-floating > .form-control-plaintext::placeholder, .form-floating > .form-control::placeholder {
+ color: var(--bs-secondary-color);
+ text-align: end;
+}
+
+.form-floating > .form-control-plaintext:focus::placeholder, .form-floating > .form-control:focus::placeholder {
+ text-align: start;
+}
\ No newline at end of file
diff --git a/extra/admin-api/Utilities/Spacebar.Client/wwwroot/css/jetbrains-mono/jetbrains-mono.css b/extra/admin-api/Utilities/Spacebar.Client/wwwroot/css/jetbrains-mono/jetbrains-mono.css
new file mode 100644
index 00000000..78aedd2b
--- /dev/null
+++ b/extra/admin-api/Utilities/Spacebar.Client/wwwroot/css/jetbrains-mono/jetbrains-mono.css
@@ -0,0 +1,118 @@
+/* source: https://gist.github.com/aasmpro/95776294ecf48bd7d0562504bad848ea */
+
+/* normal fonts */
+
+@font-face {
+ font-family: JetBrainsMono;
+ font-style: normal;
+ font-weight: 100;
+ src: url("./ttf/JetBrainsMono-Thin.ttf") format("truetype");
+ src: url("./webfonts/JetBrainsMono-Thin.woff2") format("woff2");
+}
+
+@font-face {
+ font-family: JetBrainsMono;
+ font-style: normal;
+ font-weight: 200;
+ src: url("./webfonts/JetBrainsMono-ExtraLight.woff2") format("woff2");
+}
+
+@font-face {
+ font-family: JetBrainsMono;
+ font-style: normal;
+ font-weight: 300;
+ src: url("./webfonts/JetBrainsMono-Light.woff2") format("woff2");
+}
+
+@font-face {
+ font-family: JetBrainsMono;
+ font-style: normal;
+ font-weight: 400;
+ src: url("./webfonts/JetBrainsMono-Regular.woff2") format("woff2");
+}
+
+@font-face {
+ font-family: JetBrainsMono;
+ font-style: normal;
+ font-weight: 500;
+ src: url("./webfonts/JetBrainsMono-Medium.woff2") format("woff2");
+}
+
+@font-face {
+ font-family: JetBrainsMono;
+ font-style: normal;
+ font-weight: 600;
+ src: url("./webfonts/JetBrainsMono-SemiBold.woff2") format("woff2");
+}
+
+@font-face {
+ font-family: JetBrainsMono;
+ font-style: normal;
+ font-weight: 700;
+ src: url("./webfonts/JetBrainsMono-Bold.woff2") format("woff2");
+}
+
+@font-face {
+ font-family: JetBrainsMono;
+ font-style: normal;
+ font-weight: 800;
+ src: url("./webfonts/JetBrainsMono-ExtraBold.woff2") format("woff2");
+}
+
+/* italic fonts */
+
+@font-face {
+ font-family: JetBrainsMono;
+ font-style: italic;
+ font-weight: 100;
+ src: url("./webfonts/JetBrainsMono-ThinItalic.woff2") format("woff2");
+}
+
+@font-face {
+ font-family: JetBrainsMono;
+ font-style: italic;
+ font-weight: 200;
+ src: url("./webfonts/JetBrainsMono-ExtraLightItalic.woff2") format("woff2");
+}
+
+@font-face {
+ font-family: JetBrainsMono;
+ font-style: italic;
+ font-weight: 300;
+ src: url("./webfonts/JetBrainsMono-LightItalic.woff2") format("woff2");
+}
+
+@font-face {
+ font-family: JetBrainsMono;
+ font-style: italic;
+ font-weight: 400;
+ src: url("./webfonts/JetBrainsMono-Italic.woff2") format("woff2");
+}
+
+@font-face {
+ font-family: JetBrainsMono;
+ font-style: italic;
+ font-weight: 500;
+ src: url("./webfonts/JetBrainsMono-MediumItalic.woff2") format("woff2");
+}
+
+@font-face {
+ font-family: JetBrainsMono;
+ font-style: italic;
+ font-weight: 600;
+ src: url("./webfonts/JetBrainsMono-SemiBoldItalic.woff2") format("woff2");
+}
+
+@font-face {
+ font-family: JetBrainsMono;
+ font-style: italic;
+ font-weight: 700;
+ src: url("./webfonts/JetBrainsMono-BoldItalic.woff2") format("woff2");
+}
+
+@font-face {
+ font-family: JetBrainsMono;
+ font-style: italic;
+ font-weight: 800;
+ src: url("./webfonts/JetBrainsMono-ExtraBoldItalic.woff2") format("woff2");
+}
\ No newline at end of file
diff --git a/extra/admin-api/Utilities/Spacebar.Client/wwwroot/css/jetbrains-mono/webfonts/JetBrainsMono-Bold.woff2 b/extra/admin-api/Utilities/Spacebar.Client/wwwroot/css/jetbrains-mono/webfonts/JetBrainsMono-Bold.woff2
new file mode 100644
index 00000000..4917f434
--- /dev/null
+++ b/extra/admin-api/Utilities/Spacebar.Client/wwwroot/css/jetbrains-mono/webfonts/JetBrainsMono-Bold.woff2
Binary files differdiff --git a/extra/admin-api/Utilities/Spacebar.Client/wwwroot/css/jetbrains-mono/webfonts/JetBrainsMono-BoldItalic.woff2 b/extra/admin-api/Utilities/Spacebar.Client/wwwroot/css/jetbrains-mono/webfonts/JetBrainsMono-BoldItalic.woff2
new file mode 100644
index 00000000..536d3f71
--- /dev/null
+++ b/extra/admin-api/Utilities/Spacebar.Client/wwwroot/css/jetbrains-mono/webfonts/JetBrainsMono-BoldItalic.woff2
Binary files differdiff --git a/extra/admin-api/Utilities/Spacebar.Client/wwwroot/css/jetbrains-mono/webfonts/JetBrainsMono-ExtraBold.woff2 b/extra/admin-api/Utilities/Spacebar.Client/wwwroot/css/jetbrains-mono/webfonts/JetBrainsMono-ExtraBold.woff2
new file mode 100644
index 00000000..8f88c546
--- /dev/null
+++ b/extra/admin-api/Utilities/Spacebar.Client/wwwroot/css/jetbrains-mono/webfonts/JetBrainsMono-ExtraBold.woff2
Binary files differdiff --git a/extra/admin-api/Utilities/Spacebar.Client/wwwroot/css/jetbrains-mono/webfonts/JetBrainsMono-ExtraBoldItalic.woff2 b/extra/admin-api/Utilities/Spacebar.Client/wwwroot/css/jetbrains-mono/webfonts/JetBrainsMono-ExtraBoldItalic.woff2
new file mode 100644
index 00000000..d1478bac
--- /dev/null
+++ b/extra/admin-api/Utilities/Spacebar.Client/wwwroot/css/jetbrains-mono/webfonts/JetBrainsMono-ExtraBoldItalic.woff2
Binary files differdiff --git a/extra/admin-api/Utilities/Spacebar.Client/wwwroot/css/jetbrains-mono/webfonts/JetBrainsMono-ExtraLight.woff2 b/extra/admin-api/Utilities/Spacebar.Client/wwwroot/css/jetbrains-mono/webfonts/JetBrainsMono-ExtraLight.woff2
new file mode 100644
index 00000000..b97239f3
--- /dev/null
+++ b/extra/admin-api/Utilities/Spacebar.Client/wwwroot/css/jetbrains-mono/webfonts/JetBrainsMono-ExtraLight.woff2
Binary files differdiff --git a/extra/admin-api/Utilities/Spacebar.Client/wwwroot/css/jetbrains-mono/webfonts/JetBrainsMono-ExtraLightItalic.woff2 b/extra/admin-api/Utilities/Spacebar.Client/wwwroot/css/jetbrains-mono/webfonts/JetBrainsMono-ExtraLightItalic.woff2
new file mode 100644
index 00000000..be01aaca
--- /dev/null
+++ b/extra/admin-api/Utilities/Spacebar.Client/wwwroot/css/jetbrains-mono/webfonts/JetBrainsMono-ExtraLightItalic.woff2
Binary files differdiff --git a/extra/admin-api/Utilities/Spacebar.Client/wwwroot/css/jetbrains-mono/webfonts/JetBrainsMono-Italic.woff2 b/extra/admin-api/Utilities/Spacebar.Client/wwwroot/css/jetbrains-mono/webfonts/JetBrainsMono-Italic.woff2
new file mode 100644
index 00000000..d60c270e
--- /dev/null
+++ b/extra/admin-api/Utilities/Spacebar.Client/wwwroot/css/jetbrains-mono/webfonts/JetBrainsMono-Italic.woff2
Binary files differdiff --git a/extra/admin-api/Utilities/Spacebar.Client/wwwroot/css/jetbrains-mono/webfonts/JetBrainsMono-Light.woff2 b/extra/admin-api/Utilities/Spacebar.Client/wwwroot/css/jetbrains-mono/webfonts/JetBrainsMono-Light.woff2
new file mode 100644
index 00000000..65384987
--- /dev/null
+++ b/extra/admin-api/Utilities/Spacebar.Client/wwwroot/css/jetbrains-mono/webfonts/JetBrainsMono-Light.woff2
Binary files differdiff --git a/extra/admin-api/Utilities/Spacebar.Client/wwwroot/css/jetbrains-mono/webfonts/JetBrainsMono-LightItalic.woff2 b/extra/admin-api/Utilities/Spacebar.Client/wwwroot/css/jetbrains-mono/webfonts/JetBrainsMono-LightItalic.woff2
new file mode 100644
index 00000000..66ca3d2b
--- /dev/null
+++ b/extra/admin-api/Utilities/Spacebar.Client/wwwroot/css/jetbrains-mono/webfonts/JetBrainsMono-LightItalic.woff2
Binary files differdiff --git a/extra/admin-api/Utilities/Spacebar.Client/wwwroot/css/jetbrains-mono/webfonts/JetBrainsMono-Medium.woff2 b/extra/admin-api/Utilities/Spacebar.Client/wwwroot/css/jetbrains-mono/webfonts/JetBrainsMono-Medium.woff2
new file mode 100644
index 00000000..669d04cd
--- /dev/null
+++ b/extra/admin-api/Utilities/Spacebar.Client/wwwroot/css/jetbrains-mono/webfonts/JetBrainsMono-Medium.woff2
Binary files differdiff --git a/extra/admin-api/Utilities/Spacebar.Client/wwwroot/css/jetbrains-mono/webfonts/JetBrainsMono-MediumItalic.woff2 b/extra/admin-api/Utilities/Spacebar.Client/wwwroot/css/jetbrains-mono/webfonts/JetBrainsMono-MediumItalic.woff2
new file mode 100644
index 00000000..80cfd15e
--- /dev/null
+++ b/extra/admin-api/Utilities/Spacebar.Client/wwwroot/css/jetbrains-mono/webfonts/JetBrainsMono-MediumItalic.woff2
Binary files differdiff --git a/extra/admin-api/Utilities/Spacebar.Client/wwwroot/css/jetbrains-mono/webfonts/JetBrainsMono-Regular.woff2 b/extra/admin-api/Utilities/Spacebar.Client/wwwroot/css/jetbrains-mono/webfonts/JetBrainsMono-Regular.woff2
new file mode 100644
index 00000000..40da4276
--- /dev/null
+++ b/extra/admin-api/Utilities/Spacebar.Client/wwwroot/css/jetbrains-mono/webfonts/JetBrainsMono-Regular.woff2
Binary files differdiff --git a/extra/admin-api/Utilities/Spacebar.Client/wwwroot/css/jetbrains-mono/webfonts/JetBrainsMono-SemiBold.woff2 b/extra/admin-api/Utilities/Spacebar.Client/wwwroot/css/jetbrains-mono/webfonts/JetBrainsMono-SemiBold.woff2
new file mode 100644
index 00000000..5ead7b0d
--- /dev/null
+++ b/extra/admin-api/Utilities/Spacebar.Client/wwwroot/css/jetbrains-mono/webfonts/JetBrainsMono-SemiBold.woff2
Binary files differdiff --git a/extra/admin-api/Utilities/Spacebar.Client/wwwroot/css/jetbrains-mono/webfonts/JetBrainsMono-SemiBoldItalic.woff2 b/extra/admin-api/Utilities/Spacebar.Client/wwwroot/css/jetbrains-mono/webfonts/JetBrainsMono-SemiBoldItalic.woff2
new file mode 100644
index 00000000..c5dd294b
--- /dev/null
+++ b/extra/admin-api/Utilities/Spacebar.Client/wwwroot/css/jetbrains-mono/webfonts/JetBrainsMono-SemiBoldItalic.woff2
Binary files differdiff --git a/extra/admin-api/Utilities/Spacebar.Client/wwwroot/css/jetbrains-mono/webfonts/JetBrainsMono-Thin.woff2 b/extra/admin-api/Utilities/Spacebar.Client/wwwroot/css/jetbrains-mono/webfonts/JetBrainsMono-Thin.woff2
new file mode 100644
index 00000000..17270e45
--- /dev/null
+++ b/extra/admin-api/Utilities/Spacebar.Client/wwwroot/css/jetbrains-mono/webfonts/JetBrainsMono-Thin.woff2
Binary files differdiff --git a/extra/admin-api/Utilities/Spacebar.Client/wwwroot/css/jetbrains-mono/webfonts/JetBrainsMono-ThinItalic.woff2 b/extra/admin-api/Utilities/Spacebar.Client/wwwroot/css/jetbrains-mono/webfonts/JetBrainsMono-ThinItalic.woff2
new file mode 100644
index 00000000..a6432151
--- /dev/null
+++ b/extra/admin-api/Utilities/Spacebar.Client/wwwroot/css/jetbrains-mono/webfonts/JetBrainsMono-ThinItalic.woff2
Binary files differdiff --git a/extra/admin-api/Utilities/Spacebar.Client/wwwroot/favicon.png b/extra/admin-api/Utilities/Spacebar.Client/wwwroot/favicon.png
new file mode 100644
index 00000000..8422b596
--- /dev/null
+++ b/extra/admin-api/Utilities/Spacebar.Client/wwwroot/favicon.png
Binary files differdiff --git a/extra/admin-api/Utilities/Spacebar.Client/wwwroot/icon-192.png b/extra/admin-api/Utilities/Spacebar.Client/wwwroot/icon-192.png
new file mode 100644
index 00000000..166f56da
--- /dev/null
+++ b/extra/admin-api/Utilities/Spacebar.Client/wwwroot/icon-192.png
Binary files differdiff --git a/extra/admin-api/Utilities/Spacebar.Client/wwwroot/icon-512.png b/extra/admin-api/Utilities/Spacebar.Client/wwwroot/icon-512.png
new file mode 100644
index 00000000..c2dd4842
--- /dev/null
+++ b/extra/admin-api/Utilities/Spacebar.Client/wwwroot/icon-512.png
Binary files differdiff --git a/extra/admin-api/Utilities/Spacebar.Client/wwwroot/img/icon.png b/extra/admin-api/Utilities/Spacebar.Client/wwwroot/img/icon.png
new file mode 100644
index 00000000..36b59c61
--- /dev/null
+++ b/extra/admin-api/Utilities/Spacebar.Client/wwwroot/img/icon.png
Binary files differdiff --git a/extra/admin-api/Utilities/Spacebar.Client/wwwroot/img/icon_white.png b/extra/admin-api/Utilities/Spacebar.Client/wwwroot/img/icon_white.png
new file mode 100644
index 00000000..0058bfa9
--- /dev/null
+++ b/extra/admin-api/Utilities/Spacebar.Client/wwwroot/img/icon_white.png
Binary files differdiff --git a/extra/admin-api/Utilities/Spacebar.Client/wwwroot/index.html b/extra/admin-api/Utilities/Spacebar.Client/wwwroot/index.html
new file mode 100644
index 00000000..fa8a79b8
--- /dev/null
+++ b/extra/admin-api/Utilities/Spacebar.Client/wwwroot/index.html
@@ -0,0 +1,43 @@
+<!DOCTYPE html>
+<html lang="en">
+
+ <head>
+ <meta charset="utf-8"/>
+ <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
+ <title>Spacebar.Client</title>
+ <base href="/"/>
+ <link rel="preload" id="webassembly"/>
+ <link rel="stylesheet" href="lib/bootstrap/dist/css/bootstrap.min.css"/>
+ <link rel="stylesheet" href="css/app.css"/>
+ <link rel="icon" type="image/png" href="favicon.png"/>
+ <link href="Spacebar.Client.styles.css" rel="stylesheet"/>
+ <link href="manifest.webmanifest" rel="manifest"/>
+ <link rel="apple-touch-icon" sizes="512x512" href="icon-512.png"/>
+ <link rel="apple-touch-icon" sizes="192x192" href="icon-192.png"/>
+ <script type="importmap"></script>
+ </head>
+
+ <body>
+ <div id="app">
+ <svg class="loading-progress">
+ <circle r="40%" cx="50%" cy="50%"/>
+ <circle r="40%" cx="50%" cy="50%"/>
+ <foreignObject>
+ <img id="loadingLogo" src="img/icon_white.png" alt=""/>
+ <img id="loadingLogoColored" src="img/icon.png" alt=""/>
+ </foreignObject>
+ </svg>
+ <br/>
+ <div class="loading-progress-text"></div>
+ </div>
+
+ <div id="blazor-error-ui">
+ An unhandled error has occurred.
+ <a href="." class="reload">Reload</a>
+ <span class="dismiss">🗙</span>
+ </div>
+ <script src="_framework/blazor.webassembly#[.{fingerprint}].js"></script>
+ <script>navigator.serviceWorker.register('service-worker.js', {updateViaCache: 'none'});</script>
+ </body>
+
+</html>
diff --git a/extra/admin-api/Utilities/Spacebar.Client/wwwroot/manifest.webmanifest b/extra/admin-api/Utilities/Spacebar.Client/wwwroot/manifest.webmanifest
new file mode 100644
index 00000000..a30ef2e2
--- /dev/null
+++ b/extra/admin-api/Utilities/Spacebar.Client/wwwroot/manifest.webmanifest
@@ -0,0 +1,22 @@
+{
+ "name": "Spacebar.Client",
+ "short_name": "Spacebar.Client",
+ "id": "./",
+ "start_url": "./",
+ "display": "standalone",
+ "background_color": "#ffffff",
+ "theme_color": "#03173d",
+ "prefer_related_applications": false,
+ "icons": [
+ {
+ "src": "icon-512.png",
+ "type": "image/png",
+ "sizes": "512x512"
+ },
+ {
+ "src": "icon-192.png",
+ "type": "image/png",
+ "sizes": "192x192"
+ }
+ ]
+}
diff --git a/extra/admin-api/Utilities/Spacebar.Client/wwwroot/service-worker.js b/extra/admin-api/Utilities/Spacebar.Client/wwwroot/service-worker.js
new file mode 100644
index 00000000..fe614dae
--- /dev/null
+++ b/extra/admin-api/Utilities/Spacebar.Client/wwwroot/service-worker.js
@@ -0,0 +1,4 @@
+// In development, always fetch from the network and do not enable offline support.
+// This is because caching would make development more difficult (changes would not
+// be reflected on the first load after each change).
+self.addEventListener('fetch', () => { });
diff --git a/extra/admin-api/Utilities/Spacebar.Client/wwwroot/service-worker.published.js b/extra/admin-api/Utilities/Spacebar.Client/wwwroot/service-worker.published.js
new file mode 100644
index 00000000..51a0e5c7
--- /dev/null
+++ b/extra/admin-api/Utilities/Spacebar.Client/wwwroot/service-worker.published.js
@@ -0,0 +1,55 @@
+// Caution! Be sure you understand the caveats before publishing an application with
+// offline support. See https://aka.ms/blazor-offline-considerations
+
+self.importScripts('./service-worker-assets.js');
+self.addEventListener('install', event => event.waitUntil(onInstall(event)));
+self.addEventListener('activate', event => event.waitUntil(onActivate(event)));
+self.addEventListener('fetch', event => event.respondWith(onFetch(event)));
+
+const cacheNamePrefix = 'offline-cache-';
+const cacheName = `${cacheNamePrefix}${self.assetsManifest.version}`;
+const offlineAssetsInclude = [ /\.dll$/, /\.pdb$/, /\.wasm/, /\.html/, /\.js$/, /\.json$/, /\.css$/, /\.woff$/, /\.png$/, /\.jpe?g$/, /\.gif$/, /\.ico$/, /\.blat$/, /\.dat$/, /\.webmanifest$/ ];
+const offlineAssetsExclude = [ /^service-worker\.js$/ ];
+
+// Replace with your base path if you are hosting on a subfolder. Ensure there is a trailing '/'.
+const base = "/";
+const baseUrl = new URL(base, self.origin);
+const manifestUrlList = self.assetsManifest.assets.map(asset => new URL(asset.url, baseUrl).href);
+
+async function onInstall(event) {
+ console.info('Service worker: Install');
+
+ // Fetch and cache all matching items from the assets manifest
+ const assetsRequests = self.assetsManifest.assets
+ .filter(asset => offlineAssetsInclude.some(pattern => pattern.test(asset.url)))
+ .filter(asset => !offlineAssetsExclude.some(pattern => pattern.test(asset.url)))
+ .map(asset => new Request(asset.url, { integrity: asset.hash, cache: 'no-cache' }));
+ await caches.open(cacheName).then(cache => cache.addAll(assetsRequests));
+}
+
+async function onActivate(event) {
+ console.info('Service worker: Activate');
+
+ // Delete unused caches
+ const cacheKeys = await caches.keys();
+ await Promise.all(cacheKeys
+ .filter(key => key.startsWith(cacheNamePrefix) && key !== cacheName)
+ .map(key => caches.delete(key)));
+}
+
+async function onFetch(event) {
+ let cachedResponse = null;
+ if (event.request.method === 'GET') {
+ // For all navigation requests, try to serve index.html from cache,
+ // unless that request is for an offline resource.
+ // If you need some URLs to be server-rendered, edit the following check to exclude those URLs
+ const shouldServeIndexHtml = event.request.mode === 'navigate'
+ && !manifestUrlList.some(url => url === event.request.url);
+
+ const request = shouldServeIndexHtml ? 'index.html' : event.request;
+ const cache = await caches.open(cacheName);
+ cachedResponse = await cache.match(request);
+ }
+
+ return cachedResponse || fetch(event.request);
+}
diff --git a/nix/testVm/configuration.nix b/nix/testVm/configuration.nix
index a610e8ed..f732ffd7 100644
--- a/nix/testVm/configuration.nix
+++ b/nix/testVm/configuration.nix
@@ -59,7 +59,7 @@ in
};
offload = {
- enable = true;
+ enable = false;
gateway = {
enableIdentify = true;
enableGuildMembers = true;
@@ -77,12 +77,12 @@ in
};
cdnCs = {
- enable = false;
+ enable = true;
extraConfiguration.ConnectionStrings.Spacebar = csConnectionString;
};
uApi = {
- enable = true;
+ enable = false;
extraConfiguration.ConnectionStrings.Spacebar = csConnectionString;
};
|