diff --git a/Blink3.API/Blink3.API.csproj b/Blink3.API/Blink3.API.csproj index 0ea1d40..59ac6c5 100644 --- a/Blink3.API/Blink3.API.csproj +++ b/Blink3.API/Blink3.API.csproj @@ -12,7 +12,11 @@ + + + + diff --git a/Blink3.API/Controllers/ApiControllerBase.cs b/Blink3.API/Controllers/ApiControllerBase.cs index 5a1a41e..9218013 100644 --- a/Blink3.API/Controllers/ApiControllerBase.cs +++ b/Blink3.API/Controllers/ApiControllerBase.cs @@ -1,5 +1,11 @@ using System.Net.Mime; +using Blink3.Core.Caching; using Blink3.Core.DiscordAuth.Extensions; +using Blink3.Core.Models; +using Discord; +using Discord.Addons.Hosting.Util; +using Discord.Rest; +using Discord.WebSocket; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Swashbuckle.AspNetCore.Annotations; @@ -18,8 +24,12 @@ namespace Blink3.API.Controllers; [Route("api/[controller]")] [ApiController] [Authorize] -public abstract class ApiControllerBase : ControllerBase +public abstract class ApiControllerBase(DiscordSocketClient discordSocketClient, ICachingService cachingService) : ControllerBase { + protected readonly ICachingService CachingService = cachingService; + protected DiscordRestClient? Client; + protected readonly DiscordSocketClient DiscordBotClient = discordSocketClient; + /// /// Represents an Unauthorized Access message. /// @@ -79,4 +89,63 @@ private ObjectResult ProblemForUnauthorizedAccess() if (userId is null) return ProblemForMissingItem(); return userId != UserId ? ProblemForUnauthorizedAccess() : null; } + + protected async Task InitDiscordClientAsync() + { + await DiscordBotClient.WaitForReadyAsync(CancellationToken.None); + if (Client is not null) return; + string? accessToken = await CachingService.GetAsync($"token:{UserId}"); + if (accessToken is null) return; + + Client = new DiscordRestClient(); + await Client.LoginAsync(TokenType.Bearer, accessToken); + } + + protected async Task> GetUserGuilds() + { + await InitDiscordClientAsync(); + + List managedGuilds = await CachingService.GetOrAddAsync($"discord:guilds:{UserId}", + async () => + { + List manageable = []; + if (Client is null) return manageable; + + IAsyncEnumerable> guilds = Client.GetGuildSummariesAsync(); + await foreach (IReadOnlyCollection guildCollection in guilds) + { + manageable.AddRange(guildCollection.Where(g => g.Permissions.ManageGuild).Select(g => + new DiscordPartialGuild + { + Id = g.Id, + Name = g.Name, + IconUrl = g.IconUrl + })); + } + + return manageable; + }, TimeSpan.FromMinutes(5)); + + List discordGuildIds = DiscordBotClient.Guilds.Select(b => b.Id).ToList(); + return managedGuilds.Where(g => discordGuildIds.Contains(g.Id)).ToList(); + } + + /// + /// Checks if the user has access to the specified guild. + /// + /// The ID of the guild to check access for. + /// + /// Returns an representing a problem response if the user doesn't have access, or null if the user has access. + /// + protected async Task CheckGuildAccessAsync(ulong guildId) + { + List guilds = await GetUserGuilds(); + return guilds.Any(g => g.Id == guildId) ? null : ProblemForUnauthorizedAccess(); + } + + ~ApiControllerBase() + { + Client?.Dispose(); + Client = null; + } } \ No newline at end of file diff --git a/Blink3.API/Controllers/AuthController.cs b/Blink3.API/Controllers/AuthController.cs index 061ff5d..025c634 100644 --- a/Blink3.API/Controllers/AuthController.cs +++ b/Blink3.API/Controllers/AuthController.cs @@ -2,6 +2,7 @@ using AspNet.Security.OAuth.Discord; using Blink3.API.Interfaces; using Blink3.API.Models; +using Blink3.Core.Caching; using Blink3.Core.DiscordAuth; using Blink3.Core.DiscordAuth.Extensions; using Microsoft.AspNetCore.Authentication; @@ -18,7 +19,8 @@ namespace Blink3.API.Controllers; [Consumes(MediaTypeNames.Application.Json)] public class AuthController( IAuthenticationService authenticationService, - IDiscordTokenService discordTokenService) : ControllerBase + IDiscordTokenService discordTokenService, + ICachingService cachingService) : ControllerBase { [HttpGet("login")] [SwaggerOperation( diff --git a/Blink3.API/Controllers/BlinkGuildsController.cs b/Blink3.API/Controllers/BlinkGuildsController.cs new file mode 100644 index 0000000..1c6a2ea --- /dev/null +++ b/Blink3.API/Controllers/BlinkGuildsController.cs @@ -0,0 +1,125 @@ +using Blink3.Core.Caching; +using Blink3.Core.DTOs; +using Blink3.Core.Entities; +using Blink3.Core.Models; +using Blink3.Core.Repositories.Interfaces; +using Discord.WebSocket; +using Microsoft.AspNetCore.JsonPatch; +using Microsoft.AspNetCore.Mvc; +using Swashbuckle.AspNetCore.Annotations; + +namespace Blink3.API.Controllers; + +/// +/// Controller for performing CRUD operations on BlinkGuild items. +/// +[SwaggerTag("All CRUD operations for BlinkGuild items")] +public class BlinkGuildsController(DiscordSocketClient discordSocketClient, ICachingService cachingService, IBlinkGuildRepository blinkGuildRepository) : ApiControllerBase(discordSocketClient, cachingService) +{ + /// + /// Retrieves all BlinkGuild items that are manageable by the logged in user. + /// + /// A list of BlinkGuild objects representing the guild configurations. + [HttpGet] + [SwaggerOperation( + Summary = "Returns all BlinkGuild items", + Description = "Returns a list of all of the BlinkGuilds that are manageable by the logged in user", + OperationId = "BlinkGuilds.GetAll", + Tags = ["BlinkGuilds"] + )] + [SwaggerResponse(StatusCodes.Status200OK, "Success", typeof(IEnumerable))] + public async Task>> GetAllBlinkGuilds(CancellationToken cancellationToken) + { + List guilds = await GetUserGuilds(); + IReadOnlyCollection blinkGuilds = await blinkGuildRepository.FindByIdsAsync(guilds.Select(g => g.Id).ToHashSet()); + return Ok(blinkGuilds); + } + + /// + /// Retrieves a specific BlinkGuild item by its Id. + /// + /// The Id of the BlinkGuild item. + /// The BlinkGuild item with the specified Id. + [HttpGet("{id}")] + [SwaggerOperation( + Summary = "Returns a specific BlinkGuild item", + Description = "Returns a BlinkGuild item by Id", + OperationId = "BlinkGuilds.GetBlinkGuild", + Tags = ["BlinkGuilds"] + )] + [SwaggerResponse(StatusCodes.Status200OK, "Success", typeof(BlinkGuild))] + public async Task> GetBlinkGuild(ulong id) + { + ObjectResult? accessCheckResult = await CheckGuildAccessAsync(id); + if (accessCheckResult is not null) return accessCheckResult; + + BlinkGuild blinkGuild = await blinkGuildRepository.GetOrCreateByIdAsync(id); + return Ok(blinkGuild); + } + + /// + /// Updates the content of a specific BlinkGuild item. + /// + /// The ID of the BlinkGuild item to update. + /// The updated BlinkGuild item data. + /// + /// No content. + /// + [HttpPut("{id}")] + [SwaggerOperation( + Summary = "Updates a specific BlinkGuild item", + Description = "Updates the content of a specific BlinkGuild item", + OperationId = "BlinkGuilds.Update", + Tags = ["BlinkGuilds"] + )] + [SwaggerResponse(StatusCodes.Status204NoContent, "No content")] + public async Task UpdateBlinkGuild(ulong id, [FromBody] BlinkGuild blinkGuild, + CancellationToken cancellationToken) + { + ObjectResult? accessCheckResult = await CheckGuildAccessAsync(id); + if (accessCheckResult is not null) return accessCheckResult; + + blinkGuild.Id = id; + await blinkGuildRepository.UpdateAsync(blinkGuild, cancellationToken); + + return NoContent(); + } + + /// + /// Patches a specific BlinkGuild item. + /// Updates the content of a specific BlinkGuild item partially. + /// + /// The ID of the BlinkGuild item to patch. + /// The containing the partial update. + /// The cancellation token. + /// Returns 204 (No content) if the patch is successful. + [HttpPatch("{id}")] + [SwaggerOperation( + Summary = "Patches a specific BlinkGuild item", + Description = "Updates the content of a specific BlinkGuild item partially", + OperationId = "BlinkGuilds.Patch", + Tags = ["BlinkGuilds"] + )] + [SwaggerResponse(StatusCodes.Status204NoContent, "No content")] + public async Task PatchBlinkGuild(ulong id, [FromBody] JsonPatchDocument patchDoc, + CancellationToken cancellationToken) + { + ObjectResult? accessCheckResult = await CheckGuildAccessAsync(id); + if (accessCheckResult is not null) return accessCheckResult; + + BlinkGuild? blinkGuild = await blinkGuildRepository.GetByIdAsync(id); + if (blinkGuild is null) + { + return NotFound(); + } + patchDoc.ApplyTo(blinkGuild); + + if (!TryValidateModel(blinkGuild)) + { + return BadRequest(ModelState); + } + + await blinkGuildRepository.UpdateAsync(blinkGuild, cancellationToken); + return NoContent(); + } +} \ No newline at end of file diff --git a/Blink3.API/Controllers/GuildsController.cs b/Blink3.API/Controllers/GuildsController.cs new file mode 100644 index 0000000..74a5430 --- /dev/null +++ b/Blink3.API/Controllers/GuildsController.cs @@ -0,0 +1,75 @@ +using Blink3.Core.Caching; +using Blink3.Core.Models; +using Discord.WebSocket; +using Microsoft.AspNetCore.Mvc; +using Swashbuckle.AspNetCore.Annotations; + +namespace Blink3.API.Controllers; + +[SwaggerTag("Endpoints for getting information on discord guilds")] +public class GuildsController(DiscordSocketClient discordSocketClient, ICachingService cachingService) + : ApiControllerBase(discordSocketClient, cachingService) +{ + [HttpGet] + [SwaggerOperation( + Summary = "Returns all Discord guilds", + Description = "Returns a list of Discord guilds the currently logged in user has access to manage.", + OperationId = "Guilds.GetAll", + Tags = ["Guilds"] + )] + [SwaggerResponse(StatusCodes.Status200OK, "Success", typeof(DiscordPartialGuild[]))] + public async Task> GetAllGuilds() + { + List guilds = await GetUserGuilds(); + + return Ok(guilds); + } + + [HttpGet("{id}/categories")] + [SwaggerOperation( + Summary = "Returns all categories for a guild", + Description = "Returns a list of all Discord category channels for a given guild ID", + OperationId = "Guilds.GetCategories", + Tags = ["Guilds"] + )] + [SwaggerResponse(StatusCodes.Status200OK, "Success", typeof(DiscordPartialChannel[]))] + public async Task>> GetCategories(ulong id) + { + ObjectResult? accessCheckResult = await CheckGuildAccessAsync(id); + if (accessCheckResult is not null) return accessCheckResult; + + return DiscordBotClient.GetGuild(id).CategoryChannels + .OrderBy(c => c.Position) + .Select(c => + new DiscordPartialChannel + { + Id = c.Id, + Name = c.Name + }) + .ToList(); + } + + [HttpGet("{id}/channels")] + [SwaggerOperation( + Summary = "Returns all chanels for a guild", + Description = "Returns a list of all Discord channels for a given guild ID", + OperationId = "Guilds.GetChannels", + Tags = ["Guilds"] + )] + [SwaggerResponse(StatusCodes.Status200OK, "Success", typeof(DiscordPartialChannel[]))] + public async Task>> GetChannels(ulong id) + { + ObjectResult? accessCheckResult = await CheckGuildAccessAsync(id); + if (accessCheckResult is not null) return accessCheckResult; + + return DiscordBotClient.GetGuild(id).TextChannels + .OrderBy(c => c.Position) + .Select(c => + new DiscordPartialChannel + { + Id = c.Id, + Name = c.Name + }) + .ToList(); + } +} \ No newline at end of file diff --git a/Blink3.API/Controllers/TodoController.cs b/Blink3.API/Controllers/TodoController.cs index 8e93408..376fa33 100644 --- a/Blink3.API/Controllers/TodoController.cs +++ b/Blink3.API/Controllers/TodoController.cs @@ -1,6 +1,8 @@ +using Blink3.Core.Caching; using Blink3.Core.DTOs; using Blink3.Core.Entities; using Blink3.Core.Repositories.Interfaces; +using Discord.WebSocket; using Microsoft.AspNetCore.Mvc; using Swashbuckle.AspNetCore.Annotations; @@ -10,7 +12,7 @@ namespace Blink3.API.Controllers; /// Controller for performing CRUD operations on userTodo items. /// [SwaggerTag("All CRUD operations for todo items")] -public class TodoController(IUserTodoRepository todoRepository) : ApiControllerBase +public class TodoController(DiscordSocketClient discordSocketClient, ICachingService cachingService, IUserTodoRepository todoRepository) : ApiControllerBase(discordSocketClient, cachingService) { /// /// Retrieves all userTodo items for the current user. diff --git a/Blink3.API/Extensions/ServiceCollectionExtensions.cs b/Blink3.API/Extensions/ServiceCollectionExtensions.cs index c56c27a..61cd6ad 100644 --- a/Blink3.API/Extensions/ServiceCollectionExtensions.cs +++ b/Blink3.API/Extensions/ServiceCollectionExtensions.cs @@ -1,12 +1,10 @@ -using System.Net.Http.Headers; using System.Security.Claims; using System.Text.Json; using AspNet.Security.OAuth.Discord; -using Blink3.API.Models; +using Blink3.Core.Caching; using Blink3.Core.Configuration; using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Authentication.OAuth; -using Serilog; namespace Blink3.API.Extensions; @@ -30,67 +28,41 @@ public static void AddDiscordAuth(this IServiceCollection services, BlinkConfigu options.DefaultChallengeScheme = DiscordAuthenticationDefaults.AuthenticationScheme; }) .AddCookie() - .AddOAuth(DiscordAuthenticationDefaults.AuthenticationScheme, - options => ConfigureOAuthOptions(options, appConfig)); - } - - /// - /// Configures the OAuth authentication options for Discord. - /// - /// The to configure. - /// The instance that contains the Discord configuration. - private static void ConfigureOAuthOptions(OAuthOptions options, BlinkConfiguration appConfig) - { - options.ClientId = appConfig.Discord.ClientId; - options.ClientSecret = appConfig.Discord.ClientSecret; - options.CallbackPath = new PathString("/api/auth/callback"); - options.AuthorizationEndpoint = DiscordAuthenticationDefaults.AuthorizationEndpoint; - options.TokenEndpoint = DiscordAuthenticationDefaults.TokenEndpoint; - options.UserInformationEndpoint = DiscordAuthenticationDefaults.UserInformationEndpoint; - - options.Scope.Add("identify"); - options.Scope.Add("guilds"); - - options.Events.OnCreatingTicket = async context => - { - await FetchDiscordUserInfoAndCreateClaims(context, options); - }; + .AddDiscord(options => + { + options.ClientId = appConfig.Discord.ClientId; + options.ClientSecret = appConfig.Discord.ClientSecret; + options.CallbackPath = new PathString("/api/auth/callback"); + + options.Scope.Add("guilds"); - options.SaveTokens = true; + options.Events.OnCreatingTicket = async context => + { + // Add your extra claim for GlobalName here + if (context.User.GetProperty("global_name") is { ValueKind: JsonValueKind.String } globalNameElement && + globalNameElement.GetString() is { } globalName && + !string.IsNullOrEmpty(globalName)) + { + context.Identity?.AddClaim(new Claim(ClaimTypes.GivenName, globalName)); + } + + await SaveTokenAsync(context); + }; + }); } /// - /// Fetches Discord user information and creates claims for authentication ticket. + /// Saves the access token in the caching service. /// - /// The OAuthCreatingTicketContext object. - /// The OAuthOptions object. - private static async Task FetchDiscordUserInfoAndCreateClaims(OAuthCreatingTicketContext context, - OAuthOptions options) + /// The OAuthCreatingTicketContext. + /// A representing the asynchronous operation. + private static async Task SaveTokenAsync(OAuthCreatingTicketContext context) { - HttpRequestMessage requestMessage = new(HttpMethod.Get, options.UserInformationEndpoint); - requestMessage.Headers.Authorization = new AuthenticationHeaderValue("Bearer", context.AccessToken); - HttpResponseMessage backchannelResponse = - await options.Backchannel.SendAsync(requestMessage, context.HttpContext.RequestAborted); - - if (backchannelResponse.IsSuccessStatusCode) - { - string userInformationJson = await backchannelResponse.Content.ReadAsStringAsync(); - DiscordUserIdentity userInformation = JsonSerializer.Deserialize(userInformationJson) - ?? throw new InvalidOperationException( - "Unable to parse json response from user information endpoint."); - - context.Identity?.AddClaim(new Claim(ClaimTypes.NameIdentifier, userInformation.Id)); - context.Identity?.AddClaim(new Claim(ClaimTypes.Name, userInformation.Username)); - - if (userInformation.GlobalName is not null) - context.Identity?.AddClaim(new Claim(ClaimTypes.GivenName, userInformation.GlobalName)); - - if (userInformation.Locale is not null) - context.Identity?.AddClaim(new Claim(ClaimTypes.Locality, userInformation.Locale)); - } - else + ICachingService cachingService = context.HttpContext.RequestServices.GetRequiredService(); + string? nameIdentifierClaim = context.Identity?.FindFirst(c => c.Type == ClaimTypes.NameIdentifier)?.Value; + if (nameIdentifierClaim is not null && context.AccessToken is not null) { - Log.Warning("Could not retrieve user data for token {Token}", context.AccessToken); + await cachingService.SetAsync($"token:{nameIdentifierClaim}", context.AccessToken, context.ExpiresIn); } } } \ No newline at end of file diff --git a/Blink3.API/Models/DiscordUserIdentity.cs b/Blink3.API/Models/DiscordUserIdentity.cs deleted file mode 100644 index 37c2a15..0000000 --- a/Blink3.API/Models/DiscordUserIdentity.cs +++ /dev/null @@ -1,43 +0,0 @@ -using System.Text.Json.Serialization; - -// ReSharper disable PropertyCanBeMadeInitOnly.Global - -namespace Blink3.API.Models; - -public class DiscordUserIdentity -{ - [JsonPropertyName("id")] public string Id { get; set; } = null!; - - [JsonPropertyName("username")] public string Username { get; set; } = null!; - - [JsonPropertyName("discriminator")] public string Discriminator { get; set; } = null!; - - [JsonPropertyName("global_name")] public string? GlobalName { get; set; } - - [JsonPropertyName("avatar")] public string? Avatar { get; set; } - - [JsonPropertyName("bot")] public bool? Bot { get; set; } - - [JsonPropertyName("system")] public bool? System { get; set; } - - [JsonPropertyName("mfa_enabled")] public bool? MfaEnabled { get; set; } - - [JsonPropertyName("banner")] public string? Banner { get; set; } - - [JsonPropertyName("accent_color")] public int? AccentColor { get; set; } - - [JsonPropertyName("locale")] public string? Locale { get; set; } - - [JsonPropertyName("verified")] public bool? Verified { get; set; } - - [JsonPropertyName("email")] public string? Email { get; set; } - - [JsonPropertyName("flags")] public int? Flags { get; set; } - - [JsonPropertyName("premium_type")] public int? PremiumType { get; set; } - - [JsonPropertyName("public_flags")] public int? PublicFlags { get; set; } - - [JsonPropertyName("avatar_decoration")] - public string? AvatarDecoration { get; set; } -} \ No newline at end of file diff --git a/Blink3.API/Program.cs b/Blink3.API/Program.cs index d9580b8..b453d7b 100644 --- a/Blink3.API/Program.cs +++ b/Blink3.API/Program.cs @@ -4,8 +4,11 @@ using Blink3.Core.Caching.Extensions; using Blink3.Core.Configuration; using Blink3.Core.Configuration.Extensions; +using Blink3.Core.Helpers; using Blink3.DataAccess.Extensions; -using Discord.Rest; +using Discord; +using Discord.Addons.Hosting; +using Discord.WebSocket; using Microsoft.AspNetCore.HttpOverrides; using Serilog; using Serilog.Events; @@ -22,12 +25,16 @@ // Logging builder.Host.UseSerilog(); - + // Problem details builder.Services.AddProblemDetails(); // Controllers - builder.Services.AddControllers(); + builder.Services.AddControllers().AddNewtonsoftJson(options => + { + options.SerializerSettings.Converters.Add(new ULongToStringConverter()); + options.SerializerSettings.Converters.Add(new NullableULongToStringConverter()); + }); // Swagger docs builder.Services.AddEndpointsApiExplorer(); @@ -37,6 +44,19 @@ builder.Services.AddAppConfiguration(builder.Configuration); BlinkConfiguration appConfig = builder.Services.GetAppConfiguration(); + // Discord socket client + builder.Services.AddDiscordHost((config, _) => + { + config.SocketConfig = new DiscordSocketConfig + { + LogLevel = LogSeverity.Verbose, + MessageCacheSize = 0, + GatewayIntents = GatewayIntents.Guilds + }; + + config.Token = appConfig.Discord.BotToken; + }); + // Add forwarded headers builder.Services.Configure(options => { @@ -62,10 +82,7 @@ // Add Data Access layer and cache provider builder.Services.AddDataAccess(appConfig); builder.Services.AddCaching(appConfig); - - // Add Discord Rest Client and startup service - builder.Services.AddSingleton(); - builder.Services.AddHostedService(); + builder.Services.AddSession(); // For getting discord tokens builder.Services.AddHttpClient(); @@ -75,7 +92,7 @@ builder.Services.AddDiscordAuth(appConfig); WebApplication app = builder.Build(); - + if (!app.Environment.IsDevelopment()) { app.UseForwardedHeaders(); @@ -92,6 +109,8 @@ // Use Cors app.UseCors("CorsPolicy"); + app.UseSession(); + // Add authentication / authorization middleware app.UseAuthentication(); app.UseAuthorization(); diff --git a/Blink3.API/Services/DiscordStartupService.cs b/Blink3.API/Services/DiscordStartupService.cs deleted file mode 100644 index 30aeb2d..0000000 --- a/Blink3.API/Services/DiscordStartupService.cs +++ /dev/null @@ -1,45 +0,0 @@ -using System.Diagnostics.CodeAnalysis; -using Blink3.Core.Configuration; -using Discord; -using Discord.Rest; -using Microsoft.Extensions.Options; - -namespace Blink3.API.Services; - -/// -/// The DiscordStartupService class is responsible for logging in and logging out the Discord bot using the -/// DiscordRestClient. -/// -[SuppressMessage("ReSharper", "SuggestBaseTypeForParameterInConstructor")] -public class DiscordStartupService( - DiscordRestClient client, - IOptions config, - ILogger logger) : IHostedService -{ - /// - /// Represents the configuration settings for the Blink application. - /// - private BlinkConfiguration Config => config.Value; - - /// - /// Starts the asynchronous process of logging in the Discord client with the provided bot token. - /// - /// A cancellation token that can be used to cancel the operation. - /// A task representing the asynchronous operation. - public async Task StartAsync(CancellationToken cancellationToken) - { - await client.LoginAsync(TokenType.Bot, Config.Discord.BotToken); - logger.LogInformation("Logged in as {botUser}#{botDiscriminator}.", client.CurrentUser.Username, - client.CurrentUser.Discriminator); - } - - /// - /// Stops the Discord startup service. - /// - /// Cancellation token for stopping the service. - /// A task representing the asynchronous operation. - public async Task StopAsync(CancellationToken cancellationToken) - { - await client.LogoutAsync(); - } -} \ No newline at end of file diff --git a/Blink3.Core/Caching/Distributed/DistributedCachingService.cs b/Blink3.Core/Caching/Distributed/DistributedCachingService.cs new file mode 100644 index 0000000..a57830c --- /dev/null +++ b/Blink3.Core/Caching/Distributed/DistributedCachingService.cs @@ -0,0 +1,48 @@ +using Newtonsoft.Json; +using Microsoft.Extensions.Caching.Distributed; + +namespace Blink3.Core.Caching.Distributed; + +/// +public class DistributedCachingService(IDistributedCache cache) : ICachingService +{ + public async Task SetAsync(string key, object? value, TimeSpan? absoluteExpireTime = null, + CancellationToken cancellationToken = default) + { + DistributedCacheEntryOptions options = new() + { + AbsoluteExpirationRelativeToNow = absoluteExpireTime ?? TimeSpan.FromHours(1) + }; + + string jsonData = JsonConvert.SerializeObject(value); + + await cache.SetStringAsync(key, jsonData, options, cancellationToken).ConfigureAwait(false); + } + + public async Task GetAsync(string key, CancellationToken cancellationToken = default) + { + string? jsonData = await cache.GetStringAsync(key, cancellationToken).ConfigureAwait(false); + + return jsonData is null ? default : JsonConvert.DeserializeObject(jsonData); + } + + public async Task GetOrAddAsync(string key, Func> getter, TimeSpan? absoluteExpireTime = null, + CancellationToken cancellationToken = default) + { + // Try to get the value from the cache + T? result = await GetAsync(key, cancellationToken).ConfigureAwait(false); + + // If not found, call getter and save the result in cache + if (result is not null) return result; + + result = await getter(); + await SetAsync(key, result, absoluteExpireTime, cancellationToken).ConfigureAwait(false); + + return result; + } + + public async Task RemoveAsync(string key, CancellationToken cancellationToken = default) + { + await cache.RemoveAsync(key, cancellationToken).ConfigureAwait(false); + } +} \ No newline at end of file diff --git a/Blink3.Core/Caching/Extensions/ServiceCollectionExtensions.cs b/Blink3.Core/Caching/Extensions/ServiceCollectionExtensions.cs index 926f552..97a867c 100644 --- a/Blink3.Core/Caching/Extensions/ServiceCollectionExtensions.cs +++ b/Blink3.Core/Caching/Extensions/ServiceCollectionExtensions.cs @@ -1,5 +1,4 @@ -using Blink3.Core.Caching.Memory; -using Blink3.Core.Caching.Redis; +using Blink3.Core.Caching.Distributed; using Blink3.Core.Configuration; using Microsoft.Extensions.DependencyInjection; @@ -20,14 +19,15 @@ public static class ServiceCollectionExtensions /// The object that contains the Redis connection string. public static void AddCaching(this IServiceCollection services, BlinkConfiguration config) { - if (!string.IsNullOrWhiteSpace(config.Redis?.ConnectionString)) + if (string.IsNullOrWhiteSpace(config.Redis?.ConnectionString)) + { + services.AddDistributedMemoryCache(); + } + else { services.AddStackExchangeRedisCache(options => { options.Configuration = config.Redis.ConnectionString; }); - services.AddSingleton(); - return; } - services.AddMemoryCache(); - services.AddSingleton(); + services.AddSingleton(); } } \ No newline at end of file diff --git a/Blink3.Core/Caching/ICachingService.cs b/Blink3.Core/Caching/ICachingService.cs index 2374dea..760d537 100644 --- a/Blink3.Core/Caching/ICachingService.cs +++ b/Blink3.Core/Caching/ICachingService.cs @@ -16,7 +16,7 @@ public interface ICachingService /// /// Optional. The cancellation token to cancel the operation. /// A task that represents the asynchronous operation. - Task SetAsync(string key, object value, TimeSpan? absoluteExpireTime = null, + Task SetAsync(string key, object? value, TimeSpan? absoluteExpireTime = null, CancellationToken cancellationToken = default); /// @@ -30,6 +30,18 @@ Task SetAsync(string key, object value, TimeSpan? absoluteExpireTime = null, /// Task GetAsync(string key, CancellationToken cancellationToken = default); + /// + /// Retrieves the value associated with the specified key, or adds it to the cache if not found. + /// + /// The type of the value to retrieve. + /// The key of the value to retrieve. + /// The function to call to obtain the value if not found in the cache. + /// Optional. The absolute expiration time for the cached value. If not specified, the default expiration time is 1 hour. + /// Optional. The cancellation token to cancel the operation. + /// The value associated with the specified key, or the newly added value if not found in the cache. + public Task GetOrAddAsync(string key, Func> getter, TimeSpan? absoluteExpireTime = null, + CancellationToken cancellationToken = default); + /// /// Removes the cached item with the specified key. /// diff --git a/Blink3.Core/Caching/Memory/MemoryCachingService.cs b/Blink3.Core/Caching/Memory/MemoryCachingService.cs deleted file mode 100644 index 8a93ab7..0000000 --- a/Blink3.Core/Caching/Memory/MemoryCachingService.cs +++ /dev/null @@ -1,33 +0,0 @@ -using Microsoft.Extensions.Caching.Memory; - -namespace Blink3.Core.Caching.Memory; - -/// -public class MemoryCachingService(IMemoryCache cache) : ICachingService -{ - public async Task SetAsync(string key, object value, TimeSpan? absoluteExpireTime = null, - CancellationToken cancellationToken = default) - { - MemoryCacheEntryOptions cacheEntryOptions = new() - { - AbsoluteExpirationRelativeToNow = absoluteExpireTime ?? TimeSpan.FromHours(1) - }; - - cache.Set(key, value, cacheEntryOptions); - - await Task.CompletedTask.ConfigureAwait(false); - } - - public async Task GetAsync(string key, CancellationToken cancellationToken = default) - { - if (cache.TryGetValue(key, out T? value)) return await Task.FromResult(value).ConfigureAwait(false); - - return default; - } - - public async Task RemoveAsync(string key, CancellationToken cancellationToken = default) - { - cache.Remove(key); - await Task.CompletedTask.ConfigureAwait(false); - } -} \ No newline at end of file diff --git a/Blink3.Core/Caching/Redis/RedisCachingService.cs b/Blink3.Core/Caching/Redis/RedisCachingService.cs deleted file mode 100644 index d8206b0..0000000 --- a/Blink3.Core/Caching/Redis/RedisCachingService.cs +++ /dev/null @@ -1,33 +0,0 @@ -using System.Text.Json; -using Microsoft.Extensions.Caching.Distributed; - -namespace Blink3.Core.Caching.Redis; - -/// -public class RedisCachingService(IDistributedCache cache) : ICachingService -{ - public async Task SetAsync(string key, object value, TimeSpan? absoluteExpireTime = null, - CancellationToken cancellationToken = default) - { - DistributedCacheEntryOptions options = new() - { - AbsoluteExpirationRelativeToNow = absoluteExpireTime ?? TimeSpan.FromHours(1) - }; - - string jsonData = JsonSerializer.Serialize(value); - - await cache.SetStringAsync(key, jsonData, options, cancellationToken).ConfigureAwait(false); - } - - public async Task GetAsync(string key, CancellationToken cancellationToken = default) - { - string? jsonData = await cache.GetStringAsync(key, cancellationToken).ConfigureAwait(false); - - return jsonData is null ? default : JsonSerializer.Deserialize(jsonData); - } - - public async Task RemoveAsync(string key, CancellationToken cancellationToken = default) - { - await cache.RemoveAsync(key, cancellationToken).ConfigureAwait(false); - } -} \ No newline at end of file diff --git a/Blink3.Core/DiscordAuth/AuthStatus.cs b/Blink3.Core/DiscordAuth/AuthStatus.cs index 076f91f..9d59c05 100644 --- a/Blink3.Core/DiscordAuth/AuthStatus.cs +++ b/Blink3.Core/DiscordAuth/AuthStatus.cs @@ -15,8 +15,6 @@ public class AuthStatus public string? GlobalName { get; set; } - public string? Locale { get; set; } - /// /// Gets or sets the authentication status of the user. /// @@ -59,9 +57,6 @@ public IEnumerable ToClaims() if (!string.IsNullOrWhiteSpace(GlobalName)) claims.Add(new Claim(ClaimTypes.GivenName, GlobalName)); - if (!string.IsNullOrWhiteSpace(Locale)) - claims.Add(new Claim(ClaimTypes.Locality, Locale)); - return claims; } } \ No newline at end of file diff --git a/Blink3.Core/DiscordAuth/Extensions/ClaimsPrincipleExtensions.cs b/Blink3.Core/DiscordAuth/Extensions/ClaimsPrincipleExtensions.cs index c9d83cc..2f0cf78 100644 --- a/Blink3.Core/DiscordAuth/Extensions/ClaimsPrincipleExtensions.cs +++ b/Blink3.Core/DiscordAuth/Extensions/ClaimsPrincipleExtensions.cs @@ -49,7 +49,6 @@ public static AuthStatus GetAuthStatusModel(this ClaimsPrincipal user) Id = user.Claims.FirstOrDefault(c => c.Type == ClaimTypes.NameIdentifier)?.Value ?? string.Empty, Username = user.Claims.FirstOrDefault(c => c.Type == ClaimTypes.Name)?.Value ?? string.Empty, GlobalName = user.Claims.FirstOrDefault(c => c.Type == ClaimTypes.GivenName)?.Value, - Locale = user.Claims.FirstOrDefault(c => c.Type == ClaimTypes.Locality)?.Value, Authenticated = user.Identity?.IsAuthenticated ?? false }; } diff --git a/Blink3.Core/Entities/BlinkGuild.cs b/Blink3.Core/Entities/BlinkGuild.cs index 74e446e..aa8ba50 100644 --- a/Blink3.Core/Entities/BlinkGuild.cs +++ b/Blink3.Core/Entities/BlinkGuild.cs @@ -34,9 +34,10 @@ public class BlinkGuild : ICacheKeyIdentifiable public string BackgroundColour { get => string.IsNullOrEmpty(_backgroundColour) - ? WordleImageConstants.BackgroundColour.ToHex() - : _backgroundColour; - set => _backgroundColour = string.IsNullOrEmpty(value) ? null : value; + ? string.Concat("#", WordleImageConstants.BackgroundColour.ToHex().AsSpan(0, 6)) + : '#' + _backgroundColour; + set => _backgroundColour = string.IsNullOrEmpty(value) ? null : + value.StartsWith('#') ? value.Substring(1, 6) : value[..6]; } /// @@ -48,8 +49,11 @@ public string BackgroundColour /// public string TextColour { - get => string.IsNullOrEmpty(_textColour) ? WordleImageConstants.TextColour.ToHex() : _textColour; - set => _textColour = string.IsNullOrEmpty(value) ? null : value; + get => string.IsNullOrEmpty(_textColour) + ? string.Concat("#", WordleImageConstants.TextColour.ToHex().AsSpan(0, 6)) + : '#' + _textColour; + set => _textColour = string.IsNullOrEmpty(value) ? null : + value.StartsWith('#') ? value.Substring(1, 6) : value[..6]; } /// @@ -62,9 +66,10 @@ public string TextColour public string CorrectTileColour { get => string.IsNullOrEmpty(_correctTileColour) - ? WordleImageConstants.CorrectTileColour.ToHex() - : _correctTileColour; - set => _correctTileColour = string.IsNullOrEmpty(value) ? null : value; + ? string.Concat("#", WordleImageConstants.CorrectTileColour.ToHex().AsSpan(0, 6)) + : '#' + _correctTileColour; + set => _correctTileColour = string.IsNullOrEmpty(value) ? null : + value.StartsWith('#') ? value.Substring(1, 6) : value[..6]; } /// @@ -77,9 +82,10 @@ public string CorrectTileColour public string MisplacedTileColour { get => string.IsNullOrEmpty(_misplacedTileColour) - ? WordleImageConstants.MisplacedTileColour.ToHex() - : _misplacedTileColour; - set => _misplacedTileColour = string.IsNullOrEmpty(value) ? null : value; + ? string.Concat("#", WordleImageConstants.MisplacedTileColour.ToHex().AsSpan(0, 6)) + : '#' + _misplacedTileColour; + set => _misplacedTileColour = string.IsNullOrEmpty(value) ? null : + value.StartsWith('#') ? value.Substring(1, 6) : value[..6]; } /// @@ -92,9 +98,10 @@ public string MisplacedTileColour public string IncorrectTileColour { get => string.IsNullOrEmpty(_incorrectTileColour) - ? WordleImageConstants.IncorrectTileColour.ToHex() - : _incorrectTileColour; - set => _incorrectTileColour = string.IsNullOrEmpty(value) ? null : value; + ? string.Concat("#", WordleImageConstants.IncorrectTileColour.ToHex().AsSpan(0, 6)) + : '#' + _incorrectTileColour; + set => _incorrectTileColour = string.IsNullOrEmpty(value) ? null : + value.StartsWith('#') ? value.Substring(1, 6) : value[..6]; } /// diff --git a/Blink3.Core/Extensions/StringExtensions.cs b/Blink3.Core/Extensions/StringExtensions.cs index c4f3f40..fd6976f 100644 --- a/Blink3.Core/Extensions/StringExtensions.cs +++ b/Blink3.Core/Extensions/StringExtensions.cs @@ -19,6 +19,17 @@ public static ulong ToUlong(this string input) return result; } + /// + /// Tries to convert the input string to an unsigned long (ulong) value. + /// + /// The input string to convert. + /// When this method returns, contains the converted ulong value if the conversion succeeded, or zero if the conversion failed. + /// True if the conversion succeeded; otherwise, false. + public static bool TryToUlong(this string input, out ulong result) + { + return ulong.TryParse(input, out result); + } + /// /// Converts a string to title case. /// diff --git a/Blink3.Core/Helpers/NullableULongToStringConverter.cs b/Blink3.Core/Helpers/NullableULongToStringConverter.cs new file mode 100644 index 0000000..7149cc5 --- /dev/null +++ b/Blink3.Core/Helpers/NullableULongToStringConverter.cs @@ -0,0 +1,17 @@ +using Newtonsoft.Json; + +namespace Blink3.Core.Helpers; + +public class NullableULongToStringConverter : JsonConverter +{ + public override ulong? ReadJson(JsonReader reader, Type objectType, ulong? existingValue, bool hasExistingValue, JsonSerializer serializer) + { + object? value = reader.Value; + return value is null ? null : ulong.Parse((string)value); + } + + public override void WriteJson(JsonWriter writer, ulong? value, JsonSerializer serializer) + { + writer.WriteValue(value?.ToString() ?? ""); + } +} \ No newline at end of file diff --git a/Blink3.Core/Helpers/ULongToStringConverter.cs b/Blink3.Core/Helpers/ULongToStringConverter.cs new file mode 100644 index 0000000..ffe7740 --- /dev/null +++ b/Blink3.Core/Helpers/ULongToStringConverter.cs @@ -0,0 +1,16 @@ +using Newtonsoft.Json; + +namespace Blink3.Core.Helpers; + +public class ULongToStringConverter : JsonConverter +{ + public override ulong ReadJson(JsonReader reader, Type objectType, ulong existingValue, bool hasExistingValue, JsonSerializer serializer) + { + return ulong.Parse((string)reader.Value!); + } + + public override void WriteJson(JsonWriter writer, ulong value, JsonSerializer serializer) + { + writer.WriteValue(value.ToString()); + } +} \ No newline at end of file diff --git a/Blink3.Core/Models/DiscordPartialChannel.cs b/Blink3.Core/Models/DiscordPartialChannel.cs new file mode 100644 index 0000000..c76f95c --- /dev/null +++ b/Blink3.Core/Models/DiscordPartialChannel.cs @@ -0,0 +1,7 @@ +namespace Blink3.Core.Models; + +public class DiscordPartialChannel +{ + public ulong Id { get; set; } + public string Name { get; set; } = string.Empty; +} \ No newline at end of file diff --git a/Blink3.Core/Models/DiscordPartialGuild.cs b/Blink3.Core/Models/DiscordPartialGuild.cs new file mode 100644 index 0000000..43a9eb4 --- /dev/null +++ b/Blink3.Core/Models/DiscordPartialGuild.cs @@ -0,0 +1,10 @@ +namespace Blink3.Core.Models; + +public class DiscordPartialGuild +{ + public ulong Id { get; set; } + + public string Name { get; set; } = string.Empty; + + public string? IconUrl { get; set; } +} \ No newline at end of file diff --git a/Blink3.Core/Repositories/Interfaces/IBlinkGuildRepository.cs b/Blink3.Core/Repositories/Interfaces/IBlinkGuildRepository.cs index 6904a7d..8d009a8 100644 --- a/Blink3.Core/Repositories/Interfaces/IBlinkGuildRepository.cs +++ b/Blink3.Core/Repositories/Interfaces/IBlinkGuildRepository.cs @@ -9,4 +9,10 @@ namespace Blink3.Core.Repositories.Interfaces; /// public interface IBlinkGuildRepository : IGenericRepository { + /// + /// Finds and retrieves a collection of BlinkGuild entities with the specified IDs asynchronously. + /// + /// The IDs of the BlinkGuild entities to retrieve. + /// A task representing the asynchronous operation. The task result contains a read-only collection of BlinkGuild entities. + public Task> FindByIdsAsync(HashSet ids); } \ No newline at end of file diff --git a/Blink3.DataAccess/Repositories/BlinkGuildRepository.cs b/Blink3.DataAccess/Repositories/BlinkGuildRepository.cs index e0485b7..832af0a 100644 --- a/Blink3.DataAccess/Repositories/BlinkGuildRepository.cs +++ b/Blink3.DataAccess/Repositories/BlinkGuildRepository.cs @@ -1,9 +1,18 @@ using Blink3.Core.Caching; using Blink3.Core.Entities; using Blink3.Core.Repositories.Interfaces; +using Microsoft.EntityFrameworkCore; namespace Blink3.DataAccess.Repositories; /// public class BlinkGuildRepository(BlinkDbContext dbContext, ICachingService cache) - : GenericRepositoryWithCaching(dbContext, cache), IBlinkGuildRepository; \ No newline at end of file + : GenericRepositoryWithCaching(dbContext, cache), IBlinkGuildRepository +{ + private readonly BlinkDbContext _dbContext = dbContext; + + public async Task> FindByIdsAsync(HashSet ids) + { + return await _dbContext.BlinkGuilds.Where(bg => ids.Contains(bg.Id)).ToListAsync(); + } +} \ No newline at end of file diff --git a/Blink3.DataAccess/Repositories/GenericRepositoryWithCaching.cs b/Blink3.DataAccess/Repositories/GenericRepositoryWithCaching.cs index 3a07ddb..a672738 100644 --- a/Blink3.DataAccess/Repositories/GenericRepositoryWithCaching.cs +++ b/Blink3.DataAccess/Repositories/GenericRepositoryWithCaching.cs @@ -79,14 +79,8 @@ public override async Task GetOrCreateByIdAsync(params object[] keyValues) { string cacheKey = GetCacheKey(keyValues); - T? entity = await cache.GetAsync(cacheKey).ConfigureAwait(false); - if (entity is not null) return entity; - - entity = await base.GetOrCreateByIdAsync(keyValues).ConfigureAwait(false); - - await SetEntityInCache(entity); - - return entity; + return await cache.GetOrAddAsync(cacheKey, + async () => await base.GetOrCreateByIdAsync(keyValues).ConfigureAwait(false)); } public override async Task AddAsync(T entity, CancellationToken cancellationToken = default) diff --git a/Blink3.Web/Blink3.Web.csproj b/Blink3.Web/Blink3.Web.csproj index 22d90e7..75d5f19 100644 --- a/Blink3.Web/Blink3.Web.csproj +++ b/Blink3.Web/Blink3.Web.csproj @@ -13,6 +13,7 @@ + @@ -32,6 +33,7 @@ <_ContentIncludedByDefault Remove="wwwroot\css\bootstrap\bootstrap.min.css"/> <_ContentIncludedByDefault Remove="wwwroot\css\bootstrap\bootstrap.min.css.map"/> + <_ContentIncludedByDefault Remove="wwwroot\sample-data\weather.json" /> diff --git a/Blink3.Web/Components/CreateTodoForm.razor b/Blink3.Web/Components/CreateTodoForm.razor index 5e940da..68b1ad9 100644 --- a/Blink3.Web/Components/CreateTodoForm.razor +++ b/Blink3.Web/Components/CreateTodoForm.razor @@ -1,7 +1,7 @@ @using Blink3.Web.Interfaces @using Blink3.Core.DTOs - + diff --git a/Blink3.Web/Components/TodoList.razor b/Blink3.Web/Components/TodoList.razor index 4b30dd8..dc777df 100644 --- a/Blink3.Web/Components/TodoList.razor +++ b/Blink3.Web/Components/TodoList.razor @@ -51,7 +51,7 @@ { bool confirmation = await _dialog.ShowAsync( "Are you sure you want to delete this?", - "This will delete the todo item. Once deleted can not be rolled back."); + "This will permanently delete the todo item and can not be rolled back."); if (confirmation) { diff --git a/Blink3.Web/Interfaces/IBlinkGuildConfigService.cs b/Blink3.Web/Interfaces/IBlinkGuildConfigService.cs new file mode 100644 index 0000000..f9d3cf3 --- /dev/null +++ b/Blink3.Web/Interfaces/IBlinkGuildConfigService.cs @@ -0,0 +1,27 @@ +using Blink3.Core.Entities; +using Microsoft.AspNetCore.JsonPatch; + +namespace Blink3.Web.Interfaces; + +/// +/// Represents a service for interacting with BlinkGuild configuration. +/// +public interface IBlinkGuildConfigService +{ + /// + /// Retrieves a BlinkGuild object from the API by its ID. + /// + /// The ID of the BlinkGuild to retrieve. + /// The retrieved BlinkGuild object, or null if not found. + public Task GetByIdAsync(string? id); + + /// + /// Asynchronously applies a JSON patch to update a BlinkGuild entity. + /// + /// The ID of the BlinkGuild entity to be updated. + /// The JSON patch document containing the updates. + /// + /// A boolean value indicating whether the patch operation was successful. + /// + public Task PatchAsync(string? id, JsonPatchDocument patchDocument); +} \ No newline at end of file diff --git a/Blink3.Web/Interfaces/IBlinkGuildHttpRepository.cs b/Blink3.Web/Interfaces/IBlinkGuildHttpRepository.cs new file mode 100644 index 0000000..83234e8 --- /dev/null +++ b/Blink3.Web/Interfaces/IBlinkGuildHttpRepository.cs @@ -0,0 +1,31 @@ +using Blink3.Core.DTOs; +using Blink3.Core.Entities; + +namespace Blink3.Web.Interfaces; + +/// +/// Represents an HTTP repository for accessing BlinkGuild entities. +/// +public interface IBlinkGuildHttpRepository +{ + /// + /// Retrieves all BlinkGuild entities. + /// + /// A Task that represents the asynchronous operation. The task result contains a collection of BlinkGuild entities. + public Task> GetAsync(); + + /// + /// Retrieves a BlinkGuild entity by ID asynchronously. + /// + /// The ID of the BlinkGuild entity to update. + /// A task that represents the asynchronous operation. The task result contains a collection of BlinkGuild entities. + public Task GetAsync(ulong id); + + /// + /// Updates a BlinkGuild entity with the specified ID. + /// + /// The ID of the BlinkGuild entity to update. + /// The updated BlinkGuild entity. + /// A Task representing the asynchronous operation. + public Task UpdateAsync(ulong id, BlinkGuild blinkGuild); +} \ No newline at end of file diff --git a/Blink3.Web/Interfaces/IDiscordGuildService.cs b/Blink3.Web/Interfaces/IDiscordGuildService.cs new file mode 100644 index 0000000..c504c2f --- /dev/null +++ b/Blink3.Web/Interfaces/IDiscordGuildService.cs @@ -0,0 +1,28 @@ +using Blink3.Core.Models; + +namespace Blink3.Web.Interfaces; + +/// +/// Represents a service for interacting with the Discord guild. +/// +public interface IDiscordGuildService +{ + /// + /// Retrieves the channels for a specific guild. + /// + /// The ID of the guild to retrieve the channels for. + /// + /// An asynchronous task that represents the operation. The task result contains a collection of + /// representing the channels of the specified guild. + /// + public Task> GetChannels(string? guildId); + + /// + /// Retrieves the categories of channels within a specified guild. + /// + /// The ID of the guild to retrieve the categories from. + /// + /// An enumerable collection of objects that represent the categories of channels. + /// + public Task> GetCategories(string? guildId); +} \ No newline at end of file diff --git a/Blink3.Web/Interfaces/ITodoHttpRepository.cs b/Blink3.Web/Interfaces/ITodoHttpRepository.cs index da2ac46..c2b46ee 100644 --- a/Blink3.Web/Interfaces/ITodoHttpRepository.cs +++ b/Blink3.Web/Interfaces/ITodoHttpRepository.cs @@ -15,11 +15,11 @@ public interface ITodoHttpRepository public Task> GetAsync(); /// - /// Retrieves all UserTodo entities asynchronously. + /// Retrieves a UserTodo entity by ID asynchronously. /// + /// The ID of the UserTodo entity to update. /// - /// A task representing the asynchronous operation. The task result contains a read-only collection of UserTodo - /// entities. + /// A task representing the asynchronous operation. The task result contains a UserTodo entity. /// public Task GetAsync(int id); diff --git a/Blink3.Web/Layout/MainLayout.razor b/Blink3.Web/Layout/MainLayout.razor index 9703a1b..a288e6d 100644 --- a/Blink3.Web/Layout/MainLayout.razor +++ b/Blink3.Web/Layout/MainLayout.razor @@ -1,15 +1,17 @@ @using System.Security.Claims +@using Blink3.Core.Models @inherits LayoutComponentBase @inject NavigationManager Navigation; +@inject HttpClient Client; +@inject AuthenticationStateProvider AuthStateProvider;
-
+
@@ -19,6 +21,9 @@
+ + + @* ReSharper disable InconsistentNaming *@ @code { @@ -29,7 +34,7 @@ protected override Task OnInitializedAsync() { - Navigation.LocationChanged += async (_, _) => + AuthStateProvider.AuthenticationStateChanged += async _ => { await GetNavItems(); await _sidebar.RefreshDataAsync(); @@ -62,14 +67,51 @@ AuthenticationState authState = await authenticationStateTask; ClaimsPrincipal user = authState.User; - if (user.Identity?.IsAuthenticated is true) + if (user.Identity?.IsAuthenticated is not true) return _navItems; + + _navItems.Add(new NavItem + { + Id = "2", + Href = "/todo", + IconName = IconName.PencilFill, + Text = "Todo list" + }); + + IEnumerable? guilds = await Client.GetFromJsonAsync>("api/guilds"); + + if (guilds == null) return _navItems; + + foreach (DiscordPartialGuild guild in guilds) { _navItems.Add(new NavItem { - Id = "2", - Href = "/todo", - IconName = IconName.PencilFill, - Text = "Todo list" + Id = guild.Id.ToString(), + IconName = IconName.GearFill, + Text = guild.Name + }); + _navItems.Add(new NavItem + { + Id = $"{guild.Id}_1", + ParentId = guild.Id.ToString(), + IconName = IconName.AlphabetUppercase, + Text = "Wordle settings", + Href = $"/guilds/{guild.Id}/wordle" + }); + _navItems.Add(new NavItem + { + Id = $"{guild.Id}_2", + ParentId = guild.Id.ToString(), + IconName = IconName.MicFill, + Text = "Temporary VC settings", + Href = $"/guilds/{guild.Id}/tempVC" + }); + _navItems.Add(new NavItem + { + Id = $"{guild.Id}_3", + ParentId = guild.Id.ToString(), + IconName = IconName.ShieldFill, + Text = "Staff settings", + Href = $"/guilds/{guild.Id}/staff" }); } diff --git a/Blink3.Web/Layout/MainLayout.razor.css b/Blink3.Web/Layout/MainLayout.razor.css index 019d27b..59ca493 100644 --- a/Blink3.Web/Layout/MainLayout.razor.css +++ b/Blink3.Web/Layout/MainLayout.razor.css @@ -8,13 +8,7 @@ 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; @@ -74,4 +68,4 @@ main { padding-left: 2rem !important; padding-right: 1.5rem !important; } -} +} \ No newline at end of file diff --git a/Blink3.Web/Pages/Staff.razor b/Blink3.Web/Pages/Staff.razor new file mode 100644 index 0000000..7c172bf --- /dev/null +++ b/Blink3.Web/Pages/Staff.razor @@ -0,0 +1,108 @@ +@page "/guilds/{GuildId}/Staff" + +@using Blink3.Core.Entities +@using Microsoft.AspNetCore.JsonPatch +@using Blink3.Core.Extensions +@using Blink3.Core.Models +@using Blink3.Web.Interfaces +@inject IBlinkGuildConfigService BlinkGuildConfig +@inject IDiscordGuildService DiscordGuildService + +Staff settings + +

Staff settings

+ +@if (_blinkGuild is not null) +{ + + + + @if (_channels is not null) + { +
+ + + + @foreach (DiscordPartialChannel channel in _channels) + { + + } + +
+ } + +
+ +
+
+} + +@code { + [Parameter] public string? GuildId { get; set; } + [Inject] protected ToastService ToastService { get; set; } = default!; + [Inject] protected PreloadService PreloadService { get; set; } = default!; + + private BlinkGuild? _initialGuild; + private BlinkGuild? _blinkGuild; + private IEnumerable? _channels; + private string SelectedLoggingChannelId { get; set; } = string.Empty; + + protected override async Task OnParametersSetAsync() + { + PreloadService.Show(); + await GetChannels(); + await GetGuild(); + PreloadService.Hide(); + } + + private async Task GetGuild() + { + _blinkGuild = await BlinkGuildConfig.GetByIdAsync(GuildId); + SetInitialGuild(); + } + + private async Task GetChannels() + { + _channels = await DiscordGuildService.GetChannels(GuildId); + } + + private void SetInitialGuild() + { + if (_blinkGuild is null) return; + SelectedLoggingChannelId = _blinkGuild.LoggingChannelId.ToString() ?? string.Empty; + _initialGuild = new BlinkGuild + { + Id = _blinkGuild.Id, + LoggingChannelId = _blinkGuild.LoggingChannelId + }; + } + + private async Task HandleValidSubmit() + { + if (_blinkGuild is null) + { + ToastService.Notify(new ToastMessage(ToastType.Danger, "An error occurred submitting data")); + return; + } + + JsonPatchDocument patchDocument = new(); + + _blinkGuild.LoggingChannelId = string.IsNullOrWhiteSpace(SelectedLoggingChannelId) ? null : SelectedLoggingChannelId?.ToUlong(); + + if (_initialGuild?.LoggingChannelId != _blinkGuild?.LoggingChannelId) + patchDocument.Replace(x => x.LoggingChannelId, _blinkGuild?.LoggingChannelId); + + PreloadService.Show(); + if (await BlinkGuildConfig.PatchAsync(GuildId, patchDocument)) + { + SetInitialGuild(); + ToastService.Notify(new ToastMessage(ToastType.Success, "Changes saved successfully.")); + } + else + { + ToastService.Notify(new ToastMessage(ToastType.Danger, "An error occurred while attempting to save changes.")); + } + PreloadService.Hide(); + } +} \ No newline at end of file diff --git a/Blink3.Web/Pages/TempVC.razor b/Blink3.Web/Pages/TempVC.razor new file mode 100644 index 0000000..f9ea3a3 --- /dev/null +++ b/Blink3.Web/Pages/TempVC.razor @@ -0,0 +1,107 @@ +@page "/guilds/{GuildId}/TempVC" +@using Blink3.Core.Entities +@using Microsoft.AspNetCore.JsonPatch +@using Blink3.Core.Extensions +@using Blink3.Core.Models +@using Blink3.Web.Interfaces +@inject IBlinkGuildConfigService BlinkGuildConfig +@inject IDiscordGuildService DiscordGuildService + +Temporary VC settings + +

Temporary VC settings

+ +@if (_blinkGuild is not null) +{ + + + + @if (_categories is not null) + { +
+ + + + @foreach (DiscordPartialChannel category in _categories) + { + + } + +
+ } + +
+ +
+
+} + +@code { + [Parameter] public string? GuildId { get; set; } + [Inject] protected ToastService ToastService { get; set; } = default!; + [Inject] protected PreloadService PreloadService { get; set; } = default!; + + private BlinkGuild? _initialGuild; + private BlinkGuild? _blinkGuild; + private IEnumerable? _categories; + private string SelectedCategoryId { get; set; } = string.Empty; + + protected override async Task OnParametersSetAsync() + { + PreloadService.Show(); + await GetCategories(); + await GetGuild(); + PreloadService.Hide(); + } + + private async Task GetGuild() + { + _blinkGuild = await BlinkGuildConfig.GetByIdAsync(GuildId); + SetInitialGuild(); + } + + private async Task GetCategories() + { + _categories = await DiscordGuildService.GetCategories(GuildId); + } + + private void SetInitialGuild() + { + if (_blinkGuild is null) return; + SelectedCategoryId = _blinkGuild.TemporaryVcCategoryId.ToString() ?? string.Empty; + _initialGuild = new BlinkGuild + { + Id = _blinkGuild.Id, + TemporaryVcCategoryId = _blinkGuild.TemporaryVcCategoryId + }; + } + + private async Task HandleValidSubmit() + { + if (_blinkGuild is null) + { + ToastService.Notify(new ToastMessage(ToastType.Danger, "An error occurred submitting data")); + return; + } + + JsonPatchDocument patchDocument = new(); + + _blinkGuild.TemporaryVcCategoryId = string.IsNullOrWhiteSpace(SelectedCategoryId) ? null : SelectedCategoryId?.ToUlong(); + + if (_initialGuild?.TemporaryVcCategoryId != _blinkGuild?.TemporaryVcCategoryId) + patchDocument.Replace(x => x.TemporaryVcCategoryId, _blinkGuild?.TemporaryVcCategoryId); + + PreloadService.Show(); + if (await BlinkGuildConfig.PatchAsync(GuildId, patchDocument)) + { + SetInitialGuild(); + ToastService.Notify(new ToastMessage(ToastType.Success, "Changes saved successfully.")); + } + else + { + ToastService.Notify(new ToastMessage(ToastType.Danger, "An error occurred while attempting to save changes.")); + } + PreloadService.Hide(); + } +} \ No newline at end of file diff --git a/Blink3.Web/Pages/Todo.razor b/Blink3.Web/Pages/Todo.razor index fd7c65d..5454d0d 100644 --- a/Blink3.Web/Pages/Todo.razor +++ b/Blink3.Web/Pages/Todo.razor @@ -13,10 +13,13 @@ @code { private IEnumerable Todos { get; set; } = []; [Inject] private ITodoHttpRepository TodoHttpRepository { get; set; } = null!; + [Inject] protected PreloadService PreloadService { get; set; } = default!; protected override async Task OnInitializedAsync() { + PreloadService.Show(); await UpdateTodosAsync(); + PreloadService.Hide(); } private async Task CreateTodoAsync(UserTodoDto userTodoDto) @@ -42,5 +45,4 @@ { await TodoHttpRepository.UpdateAsync(id, todo); } - } \ No newline at end of file diff --git a/Blink3.Web/Pages/Wordle.razor b/Blink3.Web/Pages/Wordle.razor new file mode 100644 index 0000000..df180e0 --- /dev/null +++ b/Blink3.Web/Pages/Wordle.razor @@ -0,0 +1,145 @@ +@page "/guilds/{GuildId}/Wordle" +@using Blink3.Core.Entities +@using Microsoft.AspNetCore.JsonPatch +@using Blink3.Web.Interfaces +@inject IBlinkGuildConfigService BlinkGuildConfig; + +Wordle settings + +

Wordle settings

+ +@if (_blinkGuild is not null) +{ + + + +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+} + + + +@code { + [Parameter] public string? GuildId { get; set; } + [Inject] protected ToastService ToastService { get; set; } = default!; + [Inject] protected PreloadService PreloadService { get; set; } = default!; + private ConfirmDialog _dialog = default!; + private BlinkGuild? _initialGuild; + private BlinkGuild? _blinkGuild; + + protected override async Task OnParametersSetAsync() + { + await GetGuild(); + } + + private async Task GetGuild() + { + PreloadService.Show(); + _blinkGuild = await BlinkGuildConfig.GetByIdAsync(GuildId); + SetInitialGuild(); + PreloadService.Hide(); + } + + private void SetInitialGuild() + { + if (_blinkGuild is null) return; + _initialGuild = new BlinkGuild + { + Id = _blinkGuild.Id, + BackgroundColour = _blinkGuild.BackgroundColour, + TextColour = _blinkGuild.TextColour, + CorrectTileColour = _blinkGuild.CorrectTileColour, + MisplacedTileColour = _blinkGuild.MisplacedTileColour, + IncorrectTileColour = _blinkGuild.IncorrectTileColour + }; + } + + private async Task HandleValidSubmit() + { + JsonPatchDocument patchDocument = new(); + + if (_initialGuild?.BackgroundColour != _blinkGuild?.BackgroundColour) + patchDocument.Replace(x => x.BackgroundColour, _blinkGuild?.BackgroundColour); + + if (_initialGuild?.TextColour != _blinkGuild?.TextColour) + patchDocument.Replace(x => x.TextColour, _blinkGuild?.TextColour); + + if (_initialGuild?.CorrectTileColour != _blinkGuild?.CorrectTileColour) + patchDocument.Replace(x => x.CorrectTileColour, _blinkGuild?.CorrectTileColour); + + if (_initialGuild?.MisplacedTileColour != _blinkGuild?.MisplacedTileColour) + patchDocument.Replace(x => x.MisplacedTileColour, _blinkGuild?.MisplacedTileColour); + + if (_initialGuild?.IncorrectTileColour != _blinkGuild?.IncorrectTileColour) + patchDocument.Replace(x => x.IncorrectTileColour, _blinkGuild?.IncorrectTileColour); + + PreloadService.Show(); + if (await BlinkGuildConfig.PatchAsync(GuildId, patchDocument)) + { + SetInitialGuild(); + ToastService.Notify(new ToastMessage(ToastType.Success, "Changes saved successfully.")); + } + else + { + ToastService.Notify(new ToastMessage(ToastType.Danger, "An error occurred while attempting to save changes.")); + } + PreloadService.Hide(); + } + + private async Task HandleReset() + { + bool confirmation = await _dialog.ShowAsync( + "Are you sure?", + "This will reset all wordle colours to the defaults and cannot be undone."); + + if (!confirmation) return; + + JsonPatchDocument patchDocument = new(); + + patchDocument.Replace(b => b.BackgroundColour, string.Empty); + patchDocument.Replace(b => b.TextColour, string.Empty); + patchDocument.Replace(b => b.CorrectTileColour, string.Empty); + patchDocument.Replace(b => b.MisplacedTileColour, string.Empty); + patchDocument.Replace(b => b.IncorrectTileColour, string.Empty); + + PreloadService.Show(); + if (await BlinkGuildConfig.PatchAsync(GuildId, patchDocument)) + { + ToastService.Notify(new ToastMessage(ToastType.Success, "Settings have been reset to default.")); + await GetGuild(); + } + else + { + ToastService.Notify(new ToastMessage(ToastType.Danger, "Unable to save changes.")); + } + PreloadService.Hide(); + } +} \ No newline at end of file diff --git a/Blink3.Web/Program.cs b/Blink3.Web/Program.cs index 365b180..4471e53 100644 --- a/Blink3.Web/Program.cs +++ b/Blink3.Web/Program.cs @@ -30,5 +30,9 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); + +builder.Services.AddScoped(); +builder.Services.AddScoped(); await builder.Build().RunAsync(); \ No newline at end of file diff --git a/Blink3.Web/Properties/launchSettings.json b/Blink3.Web/Properties/launchSettings.json index 9e27c5f..b6d874d 100644 --- a/Blink3.Web/Properties/launchSettings.json +++ b/Blink3.Web/Properties/launchSettings.json @@ -12,7 +12,7 @@ "http": { "commandName": "Project", "dotnetRunMessages": true, - "launchBrowser": true, + "launchBrowser": false, "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}", "applicationUrl": "http://localhost:5037", "environmentVariables": { @@ -22,7 +22,7 @@ "https": { "commandName": "Project", "dotnetRunMessages": true, - "launchBrowser": true, + "launchBrowser": false, "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}", "applicationUrl": "https://localhost:7041;http://localhost:5037", "environmentVariables": { @@ -31,7 +31,7 @@ }, "IIS Express": { "commandName": "IISExpress", - "launchBrowser": true, + "launchBrowser": false, "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" diff --git a/Blink3.Web/Repositories/BlinkGuildHttpRepository.cs b/Blink3.Web/Repositories/BlinkGuildHttpRepository.cs new file mode 100644 index 0000000..84e5cc1 --- /dev/null +++ b/Blink3.Web/Repositories/BlinkGuildHttpRepository.cs @@ -0,0 +1,29 @@ +using System.Net.Http.Json; +using Blink3.Core.Entities; +using Blink3.Web.Interfaces; + +namespace Blink3.Web.Repositories; + +/// +public class BlinkGuildHttpRepository(HttpClient httpClient) : IBlinkGuildHttpRepository +{ + private const string BasePath = "api/blinkguilds"; + + public async Task> GetAsync() + { + return await httpClient.GetFromJsonAsync>($"{BasePath}") ?? []; + } + + public async Task GetAsync(ulong id) + { + return await httpClient.GetFromJsonAsync($"{BasePath}/{id}") ?? new BlinkGuild + { + Id = id + }; + } + + public async Task UpdateAsync(ulong id, BlinkGuild blinkGuild) + { + await httpClient.PutAsJsonAsync($"{BasePath}/{id}", blinkGuild); + } +} \ No newline at end of file diff --git a/Blink3.Web/Services/BlinkGuildConfigService.cs b/Blink3.Web/Services/BlinkGuildConfigService.cs new file mode 100644 index 0000000..4a1786c --- /dev/null +++ b/Blink3.Web/Services/BlinkGuildConfigService.cs @@ -0,0 +1,38 @@ +using System.Net.Http.Json; +using System.Text; +using Blink3.Core.Entities; +using Blink3.Core.Helpers; +using Blink3.Web.Interfaces; +using Microsoft.AspNetCore.JsonPatch; +using Newtonsoft.Json; + +namespace Blink3.Web.Services; + +public class BlinkGuildConfigService(HttpClient httpClient) : IBlinkGuildConfigService +{ + public async Task GetByIdAsync(string? id) + { + if (string.IsNullOrWhiteSpace(id)) return null; + return await httpClient.GetFromJsonAsync($"api/BlinkGuilds/{id}"); + } + + public async Task PatchAsync(string? id, JsonPatchDocument patchDocument) + { + if (string.IsNullOrWhiteSpace(id)) return false; + HttpMethod method = new("PATCH"); + + JsonSerializerSettings options = new(); + options.Converters.Add(new ULongToStringConverter()); + options.Converters.Add(new NullableULongToStringConverter()); + + HttpRequestMessage request = new(method, $"api/BlinkGuilds/{id}") + { + Content = new StringContent(JsonConvert.SerializeObject(patchDocument, options), + Encoding.UTF8, "application/json-patch+json") + }; + + HttpResponseMessage httpResponseMessage = await httpClient.SendAsync(request); + + return httpResponseMessage.IsSuccessStatusCode; + } +} \ No newline at end of file diff --git a/Blink3.Web/Services/DiscordGuildService.cs b/Blink3.Web/Services/DiscordGuildService.cs new file mode 100644 index 0000000..bfafbc0 --- /dev/null +++ b/Blink3.Web/Services/DiscordGuildService.cs @@ -0,0 +1,22 @@ +using System.Net.Http.Json; +using Blink3.Core.Models; +using Blink3.Web.Interfaces; + +namespace Blink3.Web.Services; + +public class DiscordGuildService(HttpClient httpClient) : IDiscordGuildService +{ + public async Task> GetCategories(string? guildId) + { + if (string.IsNullOrWhiteSpace(guildId)) return []; + return await httpClient.GetFromJsonAsync>( + $"/api/Guilds/{guildId}/categories") ?? []; + } + + public async Task> GetChannels(string? guildId) + { + if (string.IsNullOrWhiteSpace(guildId)) return []; + return await httpClient.GetFromJsonAsync>( + $"/api/Guilds/{guildId}/channels") ?? []; + } +} \ No newline at end of file diff --git a/Blink3.Web/wwwroot/android-chrome-192x192.png b/Blink3.Web/wwwroot/android-chrome-192x192.png new file mode 100644 index 0000000..e72bf53 Binary files /dev/null and b/Blink3.Web/wwwroot/android-chrome-192x192.png differ diff --git a/Blink3.Web/wwwroot/android-chrome-512x512.png b/Blink3.Web/wwwroot/android-chrome-512x512.png new file mode 100644 index 0000000..4e8eb91 Binary files /dev/null and b/Blink3.Web/wwwroot/android-chrome-512x512.png differ diff --git a/Blink3.Web/wwwroot/apple-touch-icon.png b/Blink3.Web/wwwroot/apple-touch-icon.png new file mode 100644 index 0000000..3856c42 Binary files /dev/null and b/Blink3.Web/wwwroot/apple-touch-icon.png differ diff --git a/Blink3.Web/wwwroot/browserconfig.xml b/Blink3.Web/wwwroot/browserconfig.xml new file mode 100644 index 0000000..37a8a9c --- /dev/null +++ b/Blink3.Web/wwwroot/browserconfig.xml @@ -0,0 +1,9 @@ + + + + + + #0c123f + + + diff --git a/Blink3.Web/wwwroot/css/app.css b/Blink3.Web/wwwroot/css/app.css index cc1cfb1..4322fcf 100644 --- a/Blink3.Web/wwwroot/css/app.css +++ b/Blink3.Web/wwwroot/css/app.css @@ -6,36 +6,10 @@ 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 { background: lightyellow; bottom: 0; @@ -98,6 +72,10 @@ a, .btn-link { content: var(--blazor-load-percentage-text, "Loading"); } -code { - color: #c02d76; -} +:root { + --bb-sidebar2-background-color: transparent; + --bb-sidebar2-nav-item-text-color: var(--bs-nav-link-color); + --bb-sidebar2-content-border-color: var(--bs-border-color); + --bb-sidebar2-brand-image-height: 2rem; + --bb-sidebar2-brand-image-width: auto; +} \ No newline at end of file diff --git a/Blink3.Web/wwwroot/favicon-16x16.png b/Blink3.Web/wwwroot/favicon-16x16.png new file mode 100644 index 0000000..a6f9093 Binary files /dev/null and b/Blink3.Web/wwwroot/favicon-16x16.png differ diff --git a/Blink3.Web/wwwroot/favicon-32x32.png b/Blink3.Web/wwwroot/favicon-32x32.png new file mode 100644 index 0000000..5014a5c Binary files /dev/null and b/Blink3.Web/wwwroot/favicon-32x32.png differ diff --git a/Blink3.Web/wwwroot/favicon.ico b/Blink3.Web/wwwroot/favicon.ico new file mode 100644 index 0000000..f1f0bea Binary files /dev/null and b/Blink3.Web/wwwroot/favicon.ico differ diff --git a/Blink3.Web/wwwroot/favicon.png b/Blink3.Web/wwwroot/favicon.png deleted file mode 100644 index 8422b59..0000000 Binary files a/Blink3.Web/wwwroot/favicon.png and /dev/null differ diff --git a/Blink3.Web/wwwroot/icon-192.png b/Blink3.Web/wwwroot/icon-192.png deleted file mode 100644 index 166f56d..0000000 Binary files a/Blink3.Web/wwwroot/icon-192.png and /dev/null differ diff --git a/Blink3.Web/wwwroot/img/blink.png b/Blink3.Web/wwwroot/img/blink.png new file mode 100755 index 0000000..a0f27b8 Binary files /dev/null and b/Blink3.Web/wwwroot/img/blink.png differ diff --git a/Blink3.Web/wwwroot/index.html b/Blink3.Web/wwwroot/index.html index ace199a..46866ba 100644 --- a/Blink3.Web/wwwroot/index.html +++ b/Blink3.Web/wwwroot/index.html @@ -12,12 +12,21 @@ - + + + + + + + + + + - +
diff --git a/Blink3.Web/wwwroot/mstile-150x150.png b/Blink3.Web/wwwroot/mstile-150x150.png new file mode 100644 index 0000000..1403223 Binary files /dev/null and b/Blink3.Web/wwwroot/mstile-150x150.png differ diff --git a/Blink3.Web/wwwroot/sample-data/weather.json b/Blink3.Web/wwwroot/sample-data/weather.json deleted file mode 100644 index b745973..0000000 --- a/Blink3.Web/wwwroot/sample-data/weather.json +++ /dev/null @@ -1,27 +0,0 @@ -[ - { - "date": "2022-01-06", - "temperatureC": 1, - "summary": "Freezing" - }, - { - "date": "2022-01-07", - "temperatureC": 14, - "summary": "Bracing" - }, - { - "date": "2022-01-08", - "temperatureC": -13, - "summary": "Freezing" - }, - { - "date": "2022-01-09", - "temperatureC": -16, - "summary": "Balmy" - }, - { - "date": "2022-01-10", - "temperatureC": -2, - "summary": "Chilly" - } -] diff --git a/Blink3.Web/wwwroot/site.webmanifest b/Blink3.Web/wwwroot/site.webmanifest new file mode 100644 index 0000000..d979f05 --- /dev/null +++ b/Blink3.Web/wwwroot/site.webmanifest @@ -0,0 +1,19 @@ +{ + "name": "Blink", + "short_name": "Blink", + "icons": [ + { + "src": "/android-chrome-192x192.png?v=3", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "/android-chrome-512x512.png?v=3", + "sizes": "512x512", + "type": "image/png" + } + ], + "theme_color": "#ffffff", + "background_color": "#ffffff", + "display": "standalone" +}