Add WIP client

This commit is contained in:
Rory&
2026-05-08 22:24:29 +02:00
parent 759edae4ab
commit 9a0c741923
49 changed files with 1369 additions and 33 deletions
+32 -30
View File
@@ -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>
@@ -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!;
}
@@ -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);
}
}
}
@@ -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();
}
}
@@ -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);
}
}
@@ -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();
}
}
@@ -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);
});
}
}
@@ -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; }
}
}
@@ -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>
@@ -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;
}
}
@@ -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;
}
}
@@ -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;
}
}
@@ -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);
}
}
}
@@ -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);
});
}
}
@@ -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");
}
}
@@ -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>
@@ -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();
@@ -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"
}
}
}
}
@@ -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>
@@ -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; }
}
}
@@ -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
@@ -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;
}
@@ -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");
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

@@ -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>
@@ -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"
}
]
}
@@ -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', () => { });
@@ -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);
}
+3 -3
View File
@@ -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;
};