diff --git a/Attributes/AutoInitAttribute.cs b/Attributes/AutoInitAttribute.cs new file mode 100644 index 0000000..9ee8fc4 --- /dev/null +++ b/Attributes/AutoInitAttribute.cs @@ -0,0 +1,25 @@ +namespace MegghysAPI.Attributes +{ + [AttributeUsage(AttributeTargets.Method)] + public class AutoInitAttribute : Attribute + { + public AutoInitAttribute() + { + } + public AutoInitAttribute(Action postInit) + { + PostInit = postInit; + } + public AutoInitAttribute(string log) + { + LogMessage = log; + } + /// + /// 越低越优先 + /// + public int Order { get; set; } = 10; + public string LogMessage { get; set; } + public Action PostInit { get; set; } + public bool Async { get; set; } = false; + } +} diff --git a/Attributes/AutoTimerAttribute.cs b/Attributes/AutoTimerAttribute.cs new file mode 100644 index 0000000..69e4ff5 --- /dev/null +++ b/Attributes/AutoTimerAttribute.cs @@ -0,0 +1,13 @@ +namespace MegghysAPI.Attributes +{ + [AttributeUsage(AttributeTargets.Method)] + public class AutoTimerAttribute : Attribute + { + /// + /// 单位为s + /// + public int Time { get; set; } = 30; + public bool CallOnRegister { get; set; } = false; + public bool Log { get; set; } = true; + } +} diff --git a/Components/App.razor b/Components/App.razor new file mode 100644 index 0000000..346ac69 --- /dev/null +++ b/Components/App.razor @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Components/Layout/MainLayout.razor b/Components/Layout/MainLayout.razor new file mode 100644 index 0000000..302772b --- /dev/null +++ b/Components/Layout/MainLayout.razor @@ -0,0 +1,26 @@ +@inherits LayoutComponentBase + + + + MegghysAPI + + + + +
+ @Body +
+
+
+ + Documentation and demos + + About Blazor + +
+ +
+ An unhandled error has occurred. + Reload + 🗙 +
diff --git a/Components/Layout/NavMenu.razor b/Components/Layout/NavMenu.razor new file mode 100644 index 0000000..fd44fea --- /dev/null +++ b/Components/Layout/NavMenu.razor @@ -0,0 +1,20 @@ +@rendermode InteractiveServer + + + +@code { + private bool expanded = true; + public DesignThemeModes Mode { get; set; } + public OfficeColor? OfficeColor { get; set; } +} diff --git a/Components/Pages/Counter.razor b/Components/Pages/Counter.razor new file mode 100644 index 0000000..bc892df --- /dev/null +++ b/Components/Pages/Counter.razor @@ -0,0 +1,21 @@ +@page "/counter" +@rendermode InteractiveServer + +Counter + +

Counter

+ +
+ Current count: @currentCount +
+ +Click me + +@code { + private int currentCount = 0; + + private void IncrementCount() + { + currentCount++; + } +} diff --git a/Components/Pages/Error.razor b/Components/Pages/Error.razor new file mode 100644 index 0000000..576cc2d --- /dev/null +++ b/Components/Pages/Error.razor @@ -0,0 +1,36 @@ +@page "/Error" +@using System.Diagnostics + +Error + +

Error.

+

An error occurred while processing your request.

+ +@if (ShowRequestId) +{ +

+ Request ID: @RequestId +

+} + +

Development Mode

+

+ Swapping to Development environment will display more detailed information about the error that occurred. +

+

+ The Development environment shouldn't be enabled for deployed applications. + It can result in displaying sensitive information from exceptions to end users. + For local debugging, enable the Development environment by setting the ASPNETCORE_ENVIRONMENT environment variable to Development + and restarting the app. +

+ +@code{ + [CascadingParameter] + private HttpContext? HttpContext { get; set; } + + private string? RequestId { get; set; } + private bool ShowRequestId => !string.IsNullOrEmpty(RequestId); + + protected override void OnInitialized() => + RequestId = Activity.Current?.Id ?? HttpContext?.TraceIdentifier; +} diff --git a/Components/Pages/Home.razor b/Components/Pages/Home.razor new file mode 100644 index 0000000..96714a2 --- /dev/null +++ b/Components/Pages/Home.razor @@ -0,0 +1,7 @@ +@page "/" + +Home + +

Hello, world!

+ +Welcome to your new Fluent Blazor app. \ No newline at end of file diff --git a/Components/Pages/Pixiv.razor b/Components/Pages/Pixiv.razor new file mode 100644 index 0000000..f0775ef --- /dev/null +++ b/Components/Pages/Pixiv.razor @@ -0,0 +1,43 @@ +@page "/pixiv" +@rendermode InteractiveServer + +

Pixiv

+ + + 随机获取 + + +
+ + @foreach (var (index, img) in CurrentImgs.S3URL.Index()) + { + @if(index == 0) + { + + } + else + { + + } + } + + +@code { + public Modules.PixivFavoriteDownloader.Pixiv.PixivImgInfo CurrentImgs; + + protected override void OnInitialized() + { + base.OnInitialized(); + RandomGet(); + } + + public void FirstImgLoaded() + { + + } + + public void RandomGet() + { + CurrentImgs = Modules.PixivFavoriteDownloader.Favorites.OrderByRandom().First(); + } +} diff --git a/Components/Pages/Weather.razor b/Components/Pages/Weather.razor new file mode 100644 index 0000000..0506bba --- /dev/null +++ b/Components/Pages/Weather.razor @@ -0,0 +1,43 @@ +@page "/weather" +@attribute [StreamRendering] + +Weather + +

Weather

+ +

This component demonstrates showing data.

+ + + + + + + + + +@code { + private IQueryable? forecasts; + + protected override async Task OnInitializedAsync() + { + // Simulate asynchronous loading to demonstrate streaming rendering + await Task.Delay(500); + + var startDate = DateOnly.FromDateTime(DateTime.Now); + var summaries = new[] { "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" }; + forecasts = Enumerable.Range(1, 5).Select(index => new WeatherForecast + { + Date = startDate.AddDays(index), + TemperatureC = Random.Shared.Next(-20, 55), + Summary = summaries[Random.Shared.Next(summaries.Length)] + }).AsQueryable(); + } + + private class WeatherForecast + { + public DateOnly Date { get; set; } + public int TemperatureC { get; set; } + public string? Summary { get; set; } + public int TemperatureF => 32 + (int)(TemperatureC / 0.5556); + } +} diff --git a/Components/Routes.razor b/Components/Routes.razor new file mode 100644 index 0000000..f756e19 --- /dev/null +++ b/Components/Routes.razor @@ -0,0 +1,6 @@ + + + + + + diff --git a/Components/_Imports.razor b/Components/_Imports.razor new file mode 100644 index 0000000..8c17df0 --- /dev/null +++ b/Components/_Imports.razor @@ -0,0 +1,15 @@ +@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 static Microsoft.AspNetCore.Components.Web.RenderMode +@using Microsoft.AspNetCore.Components.Web.Virtualization +@using Microsoft.FluentUI.AspNetCore.Components +@using Icons = Microsoft.FluentUI.AspNetCore.Components.Icons +@using Microsoft.JSInterop +@using MegghysAPI +@using MegghysAPI.Components +@using MegghysAPI.Entities +@using static MegghysAPI.Datas +@using Masuit.Tools diff --git a/Config.cs b/Config.cs new file mode 100644 index 0000000..9fdfed1 --- /dev/null +++ b/Config.cs @@ -0,0 +1,67 @@ +using System.Text.Encodings.Web; +using System.Text.Json; +using System.Text.Unicode; + +namespace MegghysAPI +{ + public class Config + { + public static readonly JsonSerializerOptions DefaultSerializerOptions = new() + { + WriteIndented = true, + Encoder = JavaScriptEncoder.Create(UnicodeRanges.All), + }; + private static Config _oldInstance = new(); + private static Config? _instance; + public static Config Instance { get { _instance ??= Load(); return _instance; } } + public static string ConfigPath => Path.Combine(Datas.DataPath, "Config.json"); + static bool _first = true; + public static Config Load() + { + if (File.Exists(ConfigPath)) + { + try + { + var config = Newtonsoft.Json.JsonConvert.DeserializeObject(File.ReadAllText(ConfigPath))!; + _oldInstance = config; + if (_first) + config.Save(); + return _oldInstance; + } + catch + { + Console.WriteLine($"配置文件读取失败"); + return _oldInstance!; + } + } + else + { + var config = new Config(); + config.Save(); + return config; + } + } + public static void Reload() + { + _oldInstance = _instance; + _instance = null; + } + public void Save() + { + if (!Directory.Exists(Datas.DataPath)) + Directory.CreateDirectory(Datas.DataPath); + File.WriteAllText(ConfigPath, JsonSerializer.Serialize(this, DefaultSerializerOptions)); + } + + + public bool LogDanmakuError { get; set; } = false; + public string ListenHost { get; set; } = "*"; + public int ListenPort { get; set; } = 4050; + public string DBConnectString { get; set; } = $"Host=localhost;Port=5432;Username=postgres;Password=!Hzy05152484; Database=MegghysAPI;"; + public string MinIOEndpoint { get; set; } = "eternalland.top:9000"; + public string MinIOAccessKey { get; set; } = "RBzbElm21lf7sy7wK7wG"; + public string MinIOSecretKey { get; set; } = "Nko5azOSUiYgOUeLsj8hLxGz4cKC8XOcH0VS7lWq"; + public string MinIORegion { get; set; } = "cn-main"; + public string MinIOBucket { get; set; } = "general"; + } +} diff --git a/Controllers/MControllerBase.cs b/Controllers/MControllerBase.cs new file mode 100644 index 0000000..bcda191 --- /dev/null +++ b/Controllers/MControllerBase.cs @@ -0,0 +1,91 @@ +using Microsoft.AspNetCore.Mvc; +using Minio.DataModel.Result; + +namespace MegghysAPI.Controllers +{ + public interface IResponseResult + { + public string Message { get; set; } + public int Code { get; set; } + public object? Data { get; set; } + } + public class ResponseResult : IResponseResult + { + public ResponseResult() + { + + } + public ResponseResult(object? data, int code = 200, string msg = "成功") + { + Message = msg; + Code = code; + Data = data; + } + public string Message { get; set; } = ""; + public int Code { get; set; } = 0; + public object? Data { get; set; } = new(); + } + public class ResponseResult + { + public ResponseResult() + { + + } + public ResponseResult(T? data, int code = 200, string msg = "成功") + { + Message = msg; + Code = code; + Data = data; + } + public string Message { get; set; } = ""; + public int Code { get; set; } = 0; + public T? Data { get; set; } = default; + public static implicit operator ResponseResult(T value) + { + return new(value); + } + public static implicit operator ResponseResult(ResponseResult value) + { + return new(value.Data, value.Code, value.Message); + } + public static implicit operator ResponseResult(ResponseResult result) + => new(result.Data is null ? default : (T)result.Data, result.Code, result.Message); + } + public class PaginationResponse : ResponseResult + { + public PaginationResponse(T data, int pn, int ps, long total) + { + Code = 200; + Message = "成功"; + Data = data; + Ps = ps; + Pn = pn; + Total = total; + More = ps * pn < total; + } + public int Ps { get; set; } + public int Pn { get; set; } + public long Total { get; set; } + public bool More { get; set; } + public static implicit operator PaginationResponse(ResponseResult result) + => new(result.Data is null ? default : (T)result.Data, -1, -1, -1); + } + public class MControllerBase : Controller + { + protected HttpContext context => ControllerContext.HttpContext; + protected ResponseResult ResponseOK(T? data, string msg = "成功") + => new(data, 200, msg); + protected ResponseResult ResponseOKWithoutData(string msg = "成功") + => new(null, 200, msg); + protected ResponseResult ResponseUnauthorized(string msg = "未认证") + => new(null, 401, msg); + protected ResponseResult ResponseForbidden(string msg = "无权限") + => new(null, 403, msg); + protected ResponseResult ResponseNotFound(string msg = "未找到") + => new(null, 404, msg); + protected ResponseResult ResponseInternalError(string msg = "内部错误") + => new(null, 500, msg); + protected ResponseResult ResponseBadRequest(string msg = "无效操作") + => new(null, 400, msg); + } +} diff --git a/Controllers/PublicController.cs b/Controllers/PublicController.cs new file mode 100644 index 0000000..c729b74 --- /dev/null +++ b/Controllers/PublicController.cs @@ -0,0 +1,15 @@ +using Microsoft.AspNetCore.Mvc; + +namespace MegghysAPI.Controllers +{ + [Route("api/public")] + [ApiController] + public class PublicController : MControllerBase + { + [Route("header")] + public IActionResult Header() + { + return Ok(string.Join(",", context.Request.Headers.Keys)); + } + } +} diff --git a/Core/FileManager.cs b/Core/FileManager.cs new file mode 100644 index 0000000..e655efa --- /dev/null +++ b/Core/FileManager.cs @@ -0,0 +1,141 @@ +using MegghysAPI.Attributes; +using Minio; +using Minio.DataModel.Args; + +namespace MegghysAPI.Core +{ + internal class FileManager + { + + private static IMinioClient minio = new MinioClient() + .WithEndpoint(Config.Instance.MinIOEndpoint) + .WithRegion(Config.Instance.MinIORegion) + .WithCredentials(Config.Instance.MinIOAccessKey, Config.Instance.MinIOSecretKey) + .WithSSL(false) + .Build(); + public const string FavoritePath = "/Files/Favorite/"; + [AutoInit("初始化文件服务")] + public static void Init() + { + } + public static async Task DownloadFileAsync(string fileName, string path) + { + try + { + var temp = Path.Combine(Datas.TempFilePath, fileName); + if (File.Exists(temp)) + return temp; + var result = await minio.GetObjectAsync(new Minio.DataModel.Args.GetObjectArgs() + .WithBucket(Config.Instance.MinIOBucket) + .WithObject(fileName) + .WithCallbackStream(stream => + { + // 将下载流保存为本地文件 + using var fileStream = File.Create(temp); + stream.CopyTo(fileStream); + })); + + return temp; + } + catch (Exception ex) + { + Logs.Warn($"未能从储存空间下载文件: {path}{fileName}\r\n{ex}"); + return null; + } + } + public static bool UploadBytes(byte[] data, string remotePath, string contentType = "application/octet-stream", bool overwrite = false) + { + using var stream = new MemoryStream(data); + return UploadStream(stream, remotePath, contentType, overwrite: overwrite); + } + public static async Task UploadStreamAsync(Stream stream, string remotePath, string contentType = "application/octet-stream", bool overwrite = false) + { + try + { + if (!overwrite && await ContainsFileAsync(remotePath)) + return true; + // Upload a file to bucket. + var putObjectArgs = new PutObjectArgs() + .WithBucket(Config.Instance.MinIOBucket) + .WithStreamData(stream) + .WithObjectSize(stream.Length) + .WithObject(remotePath) + .WithContentType(contentType); + await minio.PutObjectAsync(putObjectArgs); + return true; + } + catch (Exception ex) + { + Logs.Warn($"未能向储存空间路径上传文件流 {remotePath}\r\n{ex}"); + return false; + } + } + public static bool UploadStream(Stream stream, string remotePath, string contentType = "application/octet-stream", bool overwrite = false) + => UploadStreamAsync(stream, remotePath, contentType, overwrite).Result; + /*public static async Task UploadFileAsync(string localFilePath, string remotePath = SetuPath, bool overwrite = false) + { + { + try + { + if (!File.Exists(localFilePath)) + return false; + return await FTPConnection.UploadFile(localFilePath, remotePath, overwrite ? FtpRemoteExists.Overwrite : FtpRemoteExists.Skip); + } + catch (Exception ex) + { + Logs.Warn($"未能向储存空间路径上传文件 {localFilePath} => {remotePath}\r\n{ex}"); + return FtpStatus.Failed; + } + } + } + public static FtpStatus UploadFile(string localFilePath, string remotePath = SetuPath, bool overwrite = false) + => UploadFileAsync(localFilePath, remotePath, overwrite).Result;*/ + public static async Task ListFilesNameAsync(string path) + { + return (await ListFilesAsync(path)).Select(x => x.Name).ToArray(); + } + + public class MItem(Minio.DataModel.Item item) : Minio.DataModel.Item + { + public string Name { get; set; } = item.Key.Contains('/') ? item.Key[(item.Key.LastIndexOf('/') + 1)..] : item.Key; + } + public static async Task ListFilesAsync(string path, bool recursive = false, bool includeUserMetadata = false) + { + try + { + var result = minio.ListObjectsEnumAsync(new ListObjectsArgs() + .WithBucket(Config.Instance.MinIOBucket) + .WithRecursive(recursive) + .WithIncludeUserMetadata(includeUserMetadata)); + + var fileList = new List(); + await foreach (var item in result) + { + fileList.Add(item); + } + return [.. fileList.Select(x => new MItem(x))]; + } + catch (Exception ex) + { + Logs.Warn($"未能列出储存空间中 {path} 的文件\r\n{ex}"); + return null; + } + } + public static async Task ContainsFileAsync(string path) + { + try + { + var stat = await minio.StatObjectAsync( + new StatObjectArgs() + .WithBucket(Config.Instance.MinIOBucket) + .WithObject(path) + ); + return true; + } + catch + { + return false; + } + } + } +} \ No newline at end of file diff --git a/Core/TimerManager.cs b/Core/TimerManager.cs new file mode 100644 index 0000000..ce3b368 --- /dev/null +++ b/Core/TimerManager.cs @@ -0,0 +1,50 @@ +using System.Diagnostics; +using System.Reflection; +using MegghysAPI.Attributes; + +namespace MegghysAPI.Core +{ + public static class TimerManager + { + + private static Dictionary timers = new(); + private static long time = 0; + [AutoInit(Order = 100)] + public static void RegisterAll() + { + System.Timers.Timer temp = new() + { + Interval = 1000, + AutoReset = true, + }; + temp.Elapsed += (_, _) => + { + if (time != 0) + timers.Where(timer => time % timer.Value.Time == 0) + .ForEach(timer => + { + Task.Run(() => + { + var sw = new Stopwatch(); + sw.Start(); + timer.Key.Invoke(null, null); + sw.Stop(); + if (timer.Value.Log) + Logs.Text($"[{sw.ElapsedMilliseconds} ms] [{timer.Key.DeclaringType.Name}.{timer.Key.Name}] 计时器触发 [{timer.Value.Time}-{time}]"); + }); + }); + time++; + }; + AppDomain.CurrentDomain.GetAssemblies() + .ForEach(a => a.GetTypes().Where(t => t.IsClass).ForEach(t => t.GetMethods().Where(m => m.GetCustomAttributes(true).FirstOrDefault(a => a is AutoTimerAttribute) != null) + .ForEach(m => + { + var attr = m.GetCustomAttributes(true).FirstOrDefault(a => a is AutoTimerAttribute) as AutoTimerAttribute; + timers.Add(m, attr); + Logs.Info($"注册自动计时器 {m.DeclaringType.Name}.{m.Name} <{attr.Time} s>"); + }))); + temp.Start(); + timers.Where(timer => timer.Value.CallOnRegister).ForEach(timer => Task.Run(() => timer.Key.Invoke(null, null))); + } + } +} diff --git a/DB.cs b/DB.cs new file mode 100644 index 0000000..cc1a95c --- /dev/null +++ b/DB.cs @@ -0,0 +1,19 @@ +using FreeSql; +using MegghysAPI.Attributes; + +namespace MegghysAPI +{ + public static class DB + { + public static IFreeSql SQL { get; private set; } + [AutoInit(Order = 0)] + internal static void InitDB() + { + SQL = new FreeSqlBuilder() + .UseConnectionString(DataType.PostgreSQL, Config.Instance.DBConnectString) + .UseAutoSyncStructure(true) + .Build(); + SQL.UseJsonMap(); + } + } +} diff --git a/Data/Config.json b/Data/Config.json new file mode 100644 index 0000000..ca98819 --- /dev/null +++ b/Data/Config.json @@ -0,0 +1,11 @@ +{ + "LogDanmakuError": false, + "ListenHost": "*", + "ListenPort": 4050, + "DBConnectString": "Host=42.193.20.136;Port=5432;Username=postgres;Password=!Hzy05152484; Database=MegghysAPI;", + "MinIOEndpoint": "eternalland.top:9000", + "MinIOAccessKey": "RBzbElm21lf7sy7wK7wG", + "MinIOSecretKey": "Nko5azOSUiYgOUeLsj8hLxGz4cKC8XOcH0VS7lWq", + "MinIORegion": "cn-main", + "MinIOBucket": "general" +} \ No newline at end of file diff --git a/Datas.cs b/Datas.cs new file mode 100644 index 0000000..4046a17 --- /dev/null +++ b/Datas.cs @@ -0,0 +1,26 @@ +using MegghysAPI.Attributes; + +namespace MegghysAPI +{ + public static class Datas + { +#if DEBUG + public const bool IsDebug = true; +#else + public const bool IsDebug = false; +#endif + public const string HIBI_URL = "https://hibi.suki.club/"; + + public static string DataPath => Path.Combine(Environment.CurrentDirectory, "Data"); + public static string TempFilePath => Path.Combine(DataPath, "Temp"); + + [AutoInit] + public static void Init() + { + if (!Directory.Exists(DataPath)) + Directory.CreateDirectory(DataPath); + if (!Directory.Exists(TempFilePath)) + Directory.CreateDirectory(TempFilePath); + } + } +} diff --git a/Entities/LogString.cs b/Entities/LogString.cs new file mode 100644 index 0000000..dc88048 --- /dev/null +++ b/Entities/LogString.cs @@ -0,0 +1,19 @@ + + +namespace MegghysAPI.Entities +{ + public struct LogString(object text, ConsoleColor? color = null) + { + public string Text { get; set; } = text?.ToString() ?? string.Empty; + public ConsoleColor? Color { get; set; } = color; + + public static implicit operator string(LogString d) => d.Text; + public static implicit operator LogString(string d) => new(d); + public static implicit operator LogString(int d) => new(d.ToString()); + public static implicit operator LogString(long d) => new(d.ToString()); + public static implicit operator LogString((string? text, ConsoleColor color) d) => new(d.text, d.color); + public static implicit operator LogString((object? text, ConsoleColor color) d) => new(d.text, d.color); + public static implicit operator (string text, ConsoleColor color)(LogString d) => (d.Text, d.Color ?? ConsoleColor.Gray); + public static implicit operator (object text, ConsoleColor color)(LogString d) => (d.Text, d.Color ?? ConsoleColor.Gray); + } +} diff --git a/Logs.cs b/Logs.cs new file mode 100644 index 0000000..0684300 --- /dev/null +++ b/Logs.cs @@ -0,0 +1,173 @@ +using System.Collections.Concurrent; +using System.Text; +using MegghysAPI.Attributes; +using MegghysAPI.Entities; + +namespace MegghysAPI +{ + public class Logs + { + public readonly static string SavePath = Path.Combine(Environment.CurrentDirectory, "Logs"); + public static string LogPath => Path.Combine(Environment.CurrentDirectory, "Logs"); + public static string LogName => Path.Combine(SavePath, DateTime.Now.ToString("yyyy-MM-dd") + ".log"); + public const ConsoleColor DefaultColor = ConsoleColor.Gray; + public static void Text(object text) + { + LogAndSave("[Normal]", ConsoleColor.Gray, true, text.ToString()); + } + public static void Info(object text) + { + LogAndSave("[Info]", ConsoleColor.Yellow, true, text.ToString()); + } + public static void Error(object text) + { + LogAndSave("[Error]", ConsoleColor.Red, true, text.ToString()); + } + public static void Warn(object text) + { + LogAndSave("[Warn]", ConsoleColor.DarkYellow, true, text.ToString()); + } + public static void Success(object text) + { + LogAndSave("[Success]", ConsoleColor.Green, true, text.ToString()); + } + public static void Text(params LogString[] text) + { + LogAndSave("[Normal]", ConsoleColor.Gray, true, text); + } + public static void Info(params LogString[] text) + { + LogAndSave("[Info]", ConsoleColor.Yellow, true, text); + } + public static void Error(params LogString[] text) + { + LogAndSave("[Error]", ConsoleColor.Red, true, text); + } + public static void Warn(params LogString[] text) + { + LogAndSave("[Warn]", ConsoleColor.DarkYellow, true, text); + } + public static void Success(params LogString[] text) + { + LogAndSave("[Success]", ConsoleColor.Green, true, text); + } + public static void TextCondition(Func condition, params LogString[] text) + { + if (condition?.Invoke() ?? true) + LogAndSave("[Normal]", ConsoleColor.Gray, true, text); + } + public static void InfoCondition(Func condition, params LogString[] text) + { + if (condition?.Invoke() ?? true) + LogAndSave("[Info]", ConsoleColor.Yellow, true, text); + } + public static void ErrorCondition(Func condition, params LogString[] text) + { + if (condition?.Invoke() ?? true) + LogAndSave("[Error]", ConsoleColor.Red, true, text); + } + public static void WarnCondition(Func condition, params LogString[] text) + { + if (condition?.Invoke() ?? true) + LogAndSave("[Warn]", ConsoleColor.DarkYellow, true, text); + } + public static void SuccesCondition(Func condition, params LogString[] text) + { + if (condition?.Invoke() ?? true) + LogAndSave("[Success]", ConsoleColor.Green, true, text); + } + public static void Info(params (object message, ConsoleColor color)[] text) + { + LogAndSave("[Info]", ConsoleColor.Yellow, true, text.Select(t => new LogString(t.message, t.color)).ToArray()); + } + public static void Text(params (object message, ConsoleColor color)[] text) + { + LogAndSave("[Normal]", ConsoleColor.Gray, true, text.Select(t => new LogString(t.message, t.color)).ToArray()); + } + public static void Error(params (object message, ConsoleColor color)[] text) + { + LogAndSave("[Error]", ConsoleColor.Red, true, text.Select(t => new LogString(t.message, t.color)).ToArray()); + } + public static void Warn(params (object message, ConsoleColor color)[] text) + { + LogAndSave("[Warn]", ConsoleColor.DarkYellow, true, text.Select(t => new LogString(t.message, t.color)).ToArray()); + } + public static void Success(params (object message, ConsoleColor color)[] text) + { + LogAndSave("[Success]", ConsoleColor.Green, true, text.Select(t => new LogString(t.message, t.color)).ToArray()); + } + internal static void Init() + { + if (!Directory.Exists(LogPath)) + Directory.CreateDirectory(LogPath); + _ = Task.Run(LogLoop); + } + public static void LogNotDisplay(object message, string prefix = "[NotDisplay]") + { + File.AppendAllText(LogName, $"{DateTime.Now:yyyy-MM-dd-HH:mm:ss} - {prefix} {message}{Environment.NewLine}", Encoding.UTF8); + } + public static BlockingCollection<(string prefix, ConsoleColor color, bool save, LogString[] msg)> logQueue = new(); + [AutoInit(Async = true)] + public static void LogLoop() + { + if (!Directory.Exists(LogPath)) + { + Directory.CreateDirectory(LogPath); + } + while (true) + { + try + { + if (logQueue.TryTake(out var log)) + { + var (prefix, color, save, message) = log; + if (save) + { + File.AppendAllText(LogName, $"{DateTime.Now:yyyy-MM-dd-HH:mm:ss} - {prefix} {string.Join("", message.Select(m => m.Text))}{Environment.NewLine}", Encoding.UTF8); + } + Console.ForegroundColor = ConsoleColor.DarkGray; + Console.Write($"{DateTime.Now:HH:mm:ss} "); + lastColor = color; + Console.ForegroundColor = color; + Console.Write($"{prefix} "); + foreach (var item in message) + { + if (item.Color.HasValue) + { + if (lastColor != item.Color) + { + lastColor = item.Color; + Console.ForegroundColor = item.Color.Value; + } + } + else if (lastColor != color) + { + lastColor = color; + Console.ForegroundColor = color; + } + + Console.Write($"{item.Text}"); + } + + Console.WriteLine(); + } + else + Thread.Sleep(1); + } + catch (Exception ex) + { + Console.WriteLine(ex); + } + } + } + static ConsoleColor? lastColor = ConsoleColor.Gray; + public static void LogAndSave(string prefix = "[Log]", ConsoleColor color = DefaultColor, bool save = true, params LogString[] message) + { + try + { + logQueue.TryAdd((prefix, color, save, message)); + } + catch (Exception ex) { Console.WriteLine(ex); } + } + } +} diff --git a/MegghysAPI.csproj b/MegghysAPI.csproj new file mode 100644 index 0000000..7972e42 --- /dev/null +++ b/MegghysAPI.csproj @@ -0,0 +1,23 @@ + + + + net9.0 + disable + enable + + + + + + + + + + + + + + + + + diff --git a/MegghysAPI.sln b/MegghysAPI.sln new file mode 100644 index 0000000..291d928 --- /dev/null +++ b/MegghysAPI.sln @@ -0,0 +1,24 @@ +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.13.35507.96 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MegghysAPI", "MegghysAPI.csproj", "{A01A05D9-A1FF-632A-4B5F-149B79FADB39}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {A01A05D9-A1FF-632A-4B5F-149B79FADB39}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A01A05D9-A1FF-632A-4B5F-149B79FADB39}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A01A05D9-A1FF-632A-4B5F-149B79FADB39}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A01A05D9-A1FF-632A-4B5F-149B79FADB39}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {4923B96A-4C69-48C8-8F8F-0372B9E8FE4D} + EndGlobalSection +EndGlobal diff --git a/Modules/PixivFavoriteDownloader.cs b/Modules/PixivFavoriteDownloader.cs new file mode 100644 index 0000000..353845b --- /dev/null +++ b/Modules/PixivFavoriteDownloader.cs @@ -0,0 +1,246 @@ +using System.Text.Json.Nodes; +using System.Web; +using FreeSql.DataAnnotations; +using MegghysAPI.Attributes; +using static MegghysAPI.Modules.PixivFavoriteDownloader.Pixiv; + +namespace MegghysAPI.Modules +{ + public static class PixivFavoriteDownloader + { + [AutoInit] + public static void Init() + { + Favorites = DB.SQL.Select().ToList(); + + Favorites.ForEach(f => + { + f.S3URL = f.URL.Select(u => new PixivURLInfo() + { + Origin = $"http://eternalland.top:9000/{Config.Instance.MinIOBucket}{FileManager.FavoritePath}{f.Id}_{f.URL.IndexOf(u)}.{u.Extension}", + Large = $"http://194.104.147.128:880/{u.Large?.Replace("i.pixiv.re", "i.pximg.net")}?referer=https://www.pixiv.net/", + Medium = $"http://194.104.147.128:880/{u.Medium?.Replace("i.pixiv.re", "i.pximg.net")}?referer=https://www.pixiv.net/", + Small = $"http://194.104.147.128:880/{u.Small?.Replace("i.pixiv.re", "i.pximg.net")}?referer=https://www.pixiv.net/", + }).ToList(); + }); + + Logs.Success($"Pixiv 收藏图片总数: {Favorites.Count}"); + } + + public static readonly string PIXIV_FAVORITE_URL = Datas.HIBI_URL + "api/pixiv/favorite?id=19045447"; + public const string PixivFavoritePath = "/files/favorite/"; + + public static List Favorites = []; + + [AutoTimer(Time = 5 * 60, CallOnRegister = true)] + public static void UpdateMyFavorite() + { + Logs.Info("正在更新收藏图片"); + var url = PIXIV_FAVORITE_URL; + var num = 0; + while (num < 5) + { + try + { + var result = Utils.RequestString(url); + var json = JsonNode.Parse(result); + if (json is null) + return; + json["illusts"]?.AsArray().ForEach(i => + { + if (!Favorites.Any(f => f.Id == (long)i["id"])) + { + var img = new PixivImgInfo(i, PixivImgType.Favorite); + Favorites.Add(img); + DB.SQL.InsertOrUpdate() + .SetSource(img) + .ExecuteAffrows(); + Logs.Success($"[{Favorites.Count}] 发现新增收藏: {Favorites.Last().Title}, 类型: {Favorites.Last().Restrict}"); + } + }); + var next = (string)json["next_url"]; + if (next is null) + break; + var list = next.Split("max_bookmark_id=", StringSplitOptions.RemoveEmptyEntries); + if (list.Length != 0) + url = PIXIV_FAVORITE_URL + $"&max_bookmark_id={list[1]}"; + else + break; + } + catch (Exception ex) + { + Logs.Error(ex); + num++; + } + } + Logs.Success($"收藏图片更新完成. 当前共 {Favorites.Count} 个"); + } + static bool uploading = false; + [AutoTimer(Time = 60 * 5, CallOnRegister = true)] + public static async Task DownloadFavoriteToBucket() + { + if (uploading) + { + Logs.Info($"正在下载收藏, 忽略"); + return; + } + uploading = true; + var files = await FileManager.ListFilesAsync(FileManager.FavoritePath, true); + if (files is null) + return; + Logs.Info($"FTP 总收藏数: {files.Length}"); + int num = 0; + for (int i = 0; i < Favorites.Count; i++) + { + var s = Favorites[i]; + try + { + num = 0; + foreach (var u in s?.URL) + { + var url = u.Origin.Replace("i.pixiv.re", "i.pximg.net"); + url = $"http://194.104.147.128:880/{url}?referer=https://www.pixiv.net/"; + var fileName = $"{s.Id}_{num}.{u.Extension}"; + if (!files.Any(f => f.Name == fileName)) + { + var data = await Utils.DownloadBytesAsync(url); + if (data.code == System.Net.HttpStatusCode.OK) + { + using var stream = new MemoryStream(data.data); + if (await FileManager.UploadStreamAsync(stream, FileManager.FavoritePath + fileName)) + Logs.Success($"已上传收藏图片 {s.Title}({s.Id})[{num + 1}-{s.URL.Count}]:{Favorites.IndexOf(s)} 至储存空间 [{(double)data.data.Length / 1024 / 1024:0.00} Mb]."); + else + Logs.Warn($"{s.Title} 上传失败"); + } + num++; + } + } + } + catch (Exception ex) { Logs.Error(ex); } + } + uploading = false; + } + + public enum PixivImgType + { + None, + Setu, + Favorite + } + public partial class Pixiv + { + public class PixivImgInfo + { + public PixivImgInfo() { Type = PixivImgType.None; } + public PixivImgInfo(PixivImgType t) { Type = t; } + public PixivImgInfo(JsonNode node, PixivImgType t) + { + Type = t; + ArgumentNullException.ThrowIfNull(node); + var tag = new List(); + node["tags"].AsArray().ForEach(t => tag.Add(new((string)t["name"], (string)t["translated_name"]))); + Id = (long)node["id"]; + Author = new() + { + UID = ((long)node["user"]["id"]).ToString(), + Name = (string)node["user"]["name"], + Account = (string)node["user"]["account"], + HeadURL = (string)node["user"]["profile_image_urls"]["medium"] + }; + Title = (string)node["title"]; + Restrict = (PixivRestrictInfo)(int)node["x_restrict"]; + Description = (string)node["caption"]; + Tags = tag; + var url = new List(); + if ((int)node["page_count"] == 1) + url.Add(new() { Origin = ((string)node["meta_single_page"]["original_image_url"]).Replace("i.pximg.net", "i.pixiv.cat") }); + else + node["meta_pages"].AsArray().ForEach(p => + { + var imgs = p["image_urls"]; + url.Add(new() + { + Origin = ((string)imgs["original"] ?? "") + .Replace("i.pximg.net", "i.pixiv.re"), + Large = ((string)imgs["large"] ?? "") + .Replace("i.pximg.net", "i.pixiv.re"), + Medium = ((string)imgs["medium"] ?? "") + .Replace("i.pximg.net", "i.pixiv.re"), + Small = ((string)imgs["square_medium"] ?? "") + .Replace("i.pximg.net", "i.pixiv.re") + }); + }); + URL = url; + Width = (int)node["width"]; + Height = (int)node["height"]; + UploadDate = DateTime.TryParse((string)node["create_date"], out var date) ? date.Ticks : 0; + ViewCount = (long)node["total_view"]; + FavoriteCount = (long)node["total_bookmarks"]; + } + public long Id { get; set; } + public PixivImgType Type { get; set; } + public string Title { get; set; } + [JsonMap] + public PixivRestrictInfo Restrict { get; set; } + [Column(DbType = "text")] + public string Description { get; set; } + [JsonMap] + public PixivAuthorInfo Author { get; set; } + public bool R18 => Restrict != PixivRestrictInfo.Normal; + [JsonMap] + public List Tags { get; set; } = []; + [JsonMap] + public List URL { get; set; } = []; + + [JsonMap] + public List S3URL { get; set; } = []; + public int Width { get; set; } + public int Height { get; set; } + public long UploadDate { set; get; } + public long ViewCount { get; set; } + public long FavoriteCount { get; set; } + } + public enum PixivRestrictInfo + { + Normal, + R18, + R18G + } + public record PixivAuthorInfo + { + public string Name { get; set; } + public string UID { get; set; } + public string Account { get; set; } + public string HeadURL { get; set; } + } + public record PixivURLInfo + { + public override string ToString() + { + return Origin; + } + public string Extension => Path.GetExtension(HttpUtility.UrlDecode(new Uri(Origin).Segments.Last()))?.Replace(".", ""); + public string Name => HttpUtility.UrlDecode(new Uri(Origin).Segments.Last()) is { } url ? $"{url}" : ""; + public string Origin { get; set; } + public string Large { get; set; } + public string Medium { get; set; } + public string Small { get; set; } + } + public record PixivTagInfo + { + public override string ToString() => $"{Name} {(TranslateName is null ? "" : $"<{TranslateName}>")}"; + public PixivTagInfo(string name, string translateName) + { + Name = name; + TranslateName = translateName; + } + public bool Contains(string tag) + { + return Name.ToLower().Contains(tag.ToLower()) || (TranslateName != null && TranslateName.ToLower().Contains(tag.ToLower())); + } + public string Name { get; set; } + public string TranslateName { get; set; } + } + } + } +} diff --git a/Program.cs b/Program.cs new file mode 100644 index 0000000..fa1c672 --- /dev/null +++ b/Program.cs @@ -0,0 +1,68 @@ +using System.Diagnostics; +using System.Reflection; +using MegghysAPI.Attributes; +using MegghysAPI.Components; +using Microsoft.FluentUI.AspNetCore.Components; + +var builder = WebApplication.CreateBuilder(args); + +// Add services to the container. +builder.Services.AddRazorComponents() + .AddInteractiveServerComponents(); +builder.Services.AddFluentUIComponents(); + +var app = builder.Build(); + +// Configure the HTTP request pipeline. +if (!app.Environment.IsDevelopment()) +{ + app.UseExceptionHandler("/Error", createScopeForErrors: true); + // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts. + app.UseHsts(); +} + +app.UseHttpsRedirection(); + +app.UseAntiforgery(); + +app.MapStaticAssets(); +app.MapRazorComponents() + .AddInteractiveServerRenderMode(); + +app.MapControllers(); +app.MapBlazorHub(); +app.MapFallbackToPage("/_Host"); + +// AutoInitAttribute +// ȡǰAssembly +var inits = new List(); +Assembly.GetExecutingAssembly() + .GetTypes() + .ForEach(t => t.GetMethods(BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public) + .Where(m => m.GetCustomAttribute() is { }).ForEach(m => inits.Add(m))); +inits = [.. inits.OrderBy(m => m.GetCustomAttribute().Order)]; +inits.ForEach(m => +{ + var sw = new Stopwatch(); + sw.Start(); + var attr = m.GetCustomAttribute(); + if (attr.LogMessage is not null) + Logs.Info(attr.LogMessage); + if (attr.Async) + { + Task.Run(() => + { + m.Invoke(null, null); + attr.PostInit?.Invoke(); + Logs.Info($"[{sw.ElapsedMilliseconds} ms] Async <{m.DeclaringType.Name}.{m.Name}> => Inited."); + }); + } + else + { + m.Invoke(null, null); + attr.PostInit?.Invoke(); + Logs.Info($"[{sw.ElapsedMilliseconds} ms] <{m.DeclaringType.Name}.{m.Name}> => Inited."); + } +}); + +app.Run(); diff --git a/Properties/launchSettings.json b/Properties/launchSettings.json new file mode 100644 index 0000000..b45214b --- /dev/null +++ b/Properties/launchSettings.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:5024", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:7009;http://localhost:5024", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } + } diff --git a/Utils.cs b/Utils.cs new file mode 100644 index 0000000..5f77d69 --- /dev/null +++ b/Utils.cs @@ -0,0 +1,106 @@ +using System.Net; + +namespace MegghysAPI +{ + public static class Utils + { + public static readonly HttpClient _client = new(new HttpClientHandler() + { + AutomaticDecompression = DecompressionMethods.All, + }) + { + //Timeout = new TimeSpan(0, 0, 15), + }; + + public static HttpResponseMessage Request(string address) => RequestAsync(address).Result; + public static async Task RequestAsync(string address, Dictionary header = null) + { + try + { + var uri = new Uri(address); + var request = new HttpRequestMessage(HttpMethod.Get, uri); + if (header?.Count > 0) + { + foreach (var h in header) + { + request.Headers.TryAddWithoutValidation(h.Key, h.Value); + } + } + return await _client.SendAsync(request); + } + catch (Exception ex) + { + Logs.Error((ex.InnerException ?? ex).Message); + return null; + } + } + public static async Task RequestStringAsync(string address, Dictionary header = null) + { + try + { + var uri = new Uri(address); + if (uri.Host.EndsWith("bilibili.com")) + { + header ??= []; + header.TryAdd("Referer", "https://" + uri.Host); + } + var result = await RequestAsync(address, header); + var content = await result?.Content.ReadAsStringAsync(); + if (result?.IsSuccessStatusCode != true) + { + Logs.Warn($"请求失败: {address} {result?.StatusCode}: {content[..100]}"); + } + return content; + } + catch (Exception ex) + { + Logs.Error(ex); + return null; + } + } + public static string RequestString(string address, Dictionary header = null) => RequestStringAsync(address, header).Result; + public static async Task<(HttpStatusCode code, byte[] data)> DownloadBytesAsync(string uri) + { + try + { + return (HttpStatusCode.OK, await _client.GetByteArrayAsync(uri)); + } + catch (HttpRequestException ex) + { + return (ex.StatusCode ?? HttpStatusCode.BadRequest, null); + } + catch (Exception ex) + { + Logs.Error(ex); // 返回false下载失败 + return (HttpStatusCode.BadRequest, null); + } + } + public static async Task<(HttpStatusCode code, string path)> DownloadFileAsync(string url, string path = null, bool resume = true) + { + path ??= Datas.TempFilePath; + var dire = Path.GetDirectoryName(path); + if (!Directory.Exists(dire)) + Directory.CreateDirectory(dire); + try + { + var result = await DownloadBytesAsync(url); + if (result.code == HttpStatusCode.OK) + { + await File.WriteAllBytesAsync(path, result.data); + return (HttpStatusCode.OK, path); + } + else + return (result.code, null); + } + catch (HttpRequestException webex) + { + return (webex.StatusCode ?? HttpStatusCode.BadRequest, null); + } + catch (Exception ex) + { + Logs.Error(ex); // 返回false下载失败 + return (HttpStatusCode.BadRequest, null); + } + } + } +} diff --git a/appsettings.Development.json b/appsettings.Development.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/appsettings.json b/appsettings.json new file mode 100644 index 0000000..10f68b8 --- /dev/null +++ b/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/wwwroot/app.css b/wwwroot/app.css new file mode 100644 index 0000000..469e373 --- /dev/null +++ b/wwwroot/app.css @@ -0,0 +1,203 @@ +@import '/_content/Microsoft.FluentUI.AspNetCore.Components/css/reboot.css'; + +body { + --body-font: "Segoe UI Variable", "Segoe UI", sans-serif; + font-family: var(--body-font); + font-size: var(--type-ramp-base-font-size); + line-height: var(--type-ramp-base-line-height); + margin: 0; +} + +body { + margin: 0; + padding: 0; + height: 100vh; + font-family: var(--body-font); + font-size: var(--type-ramp-base-font-size); + line-height: var(--type-ramp-base-line-height); + font-weight: var(--font-weight); + color: var(--neutral-foreground-rest); + background: var(--neutral-fill-layer-rest); +} + +.navmenu-icon { + display: none; +} + +.main { + min-height: calc(100dvh - 86px); + color: var(--neutral-foreground-rest); + align-items: stretch !important; +} + +.body-content { + align-self: stretch; + height: calc(100dvh - 86px) !important; + display: flex; +} + +.content { + padding: 0.5rem 1.5rem; + align-self: stretch !important; + width: 100%; +} + +.manage { + width: 100dvw; +} + +footer { + background: var(--neutral-layer-4); + color: var(--neutral-foreground-rest); + align-items: center; + padding: 10px 10px; +} + + footer a { + color: var(--neutral-foreground-rest); + text-decoration: none; + } + + footer a:focus { + outline: 1px dashed; + outline-offset: 3px; + } + + footer a:hover { + text-decoration: underline; + } + +.alert { + border: 1px dashed var(--accent-fill-rest); + padding: 5px; +} + + +#blazor-error-ui { + background: lightyellow; + bottom: 0; + box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2); + display: none; + left: 0; + padding: 0.6rem 1.25rem 0.7rem 1.25rem; + position: fixed; + width: 100%; + z-index: 1000; + margin: 20px 0; +} + + #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::before { + content: "An error has occurred. " + } + +.loading-progress { + position: relative; + display: block; + width: 8rem; + height: 8rem; + margin: 20vh auto 1rem auto; +} + + .loading-progress circle { + fill: none; + stroke: #e0e0e0; + stroke-width: 0.6rem; + transform-origin: 50% 50%; + transform: rotate(-90deg); + } + + .loading-progress circle:last-child { + 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-text { + position: absolute; + text-align: center; + font-weight: bold; + inset: calc(20vh + 3.25rem) 0 auto 0.2rem; +} + + .loading-progress-text:after { + content: var(--blazor-load-percentage-text, "Loading"); + } + +code { + color: #c02d76; +} + +@media (max-width: 600px) { + .header-gutters { + margin: 0.5rem 3rem 0.5rem 1.5rem !important; + } + + [dir="rtl"] .header-gutters { + margin: 0.5rem 1.5rem 0.5rem 3rem !important; + } + + .main { + flex-direction: column !important; + row-gap: 0 !important; + } + + nav.sitenav { + width: 100%; + height: 100%; + } + + #main-menu { + width: 100% !important; + } + + #main-menu > div:first-child:is(.expander) { + display: none; + } + + .navmenu { + width: 100%; + } + + #navmenu-toggle { + appearance: none; + } + + #navmenu-toggle ~ nav { + display: none; + } + + #navmenu-toggle:checked ~ nav { + display: block; + } + + .navmenu-icon { + cursor: pointer; + z-index: 10; + display: block; + position: absolute; + top: 15px; + left: unset; + right: 20px; + width: 20px; + height: 20px; + border: none; + } + + [dir="rtl"] .navmenu-icon { + left: 20px; + right: unset; + } +} diff --git a/wwwroot/favicon.ico b/wwwroot/favicon.ico new file mode 100644 index 0000000..e189d8e Binary files /dev/null and b/wwwroot/favicon.ico differ