diff --git a/MyApp.ServiceInterface/BackgroundMqServices.cs b/MyApp.ServiceInterface/BackgroundMqServices.cs index c9e14f4..b631799 100644 --- a/MyApp.ServiceInterface/BackgroundMqServices.cs +++ b/MyApp.ServiceInterface/BackgroundMqServices.cs @@ -1,6 +1,7 @@ using MyApp.ServiceModel; using ServiceStack; using ServiceStack.IO; +using ServiceStack.OrmLite; namespace MyApp.ServiceInterface; @@ -18,4 +19,19 @@ public async Task Any(DiskTasks request) r2.DeleteFiles(request.CdnDeleteFiles); } } -} \ No newline at end of file + + public async Task Any(AnalyticsTasks request) + { + if (request.RecordPostStat != null && !Stats.IsAdminOrModerator(request.RecordPostStat.UserName)) + { + using var analyticsDb = HostContext.AppHost.GetDbConnection(Databases.Analytics); + await analyticsDb.InsertAsync(request.RecordPostStat); + } + + if (request.RecordSearchStat != null && !Stats.IsAdminOrModerator(request.RecordSearchStat.UserName)) + { + using var analyticsDb = HostContext.AppHost.GetDbConnection(Databases.Analytics); + await analyticsDb.InsertAsync(request.RecordSearchStat); + } + } +} diff --git a/MyApp.ServiceInterface/Data/DbExtensions.cs b/MyApp.ServiceInterface/Data/DbExtensions.cs index a26020a..49205f9 100644 --- a/MyApp.ServiceInterface/Data/DbExtensions.cs +++ b/MyApp.ServiceInterface/Data/DbExtensions.cs @@ -12,34 +12,25 @@ public static SqlExpression WhereContainsTag(this SqlExpression q, s if (tag != null) { tag = tag.UrlDecode().Replace("'","").Replace("\\","").SqlVerifyFragment(); - q.UnsafeWhere("',' || tags || ',' like '%," + tag + ",%'"); + q.UnsafeWhere("',' || Tags || ',' LIKE '%," + tag + ",%'"); } return q; } - public static SqlExpression WhereSearch(this SqlExpression q, string? search) + public static SqlExpression WhereContainsTag(this SqlExpression q, string? tag) + { + if (tag != null) + { + tag = tag.UrlDecode().Replace("'","").Replace("\\","").SqlVerifyFragment(); + q.UnsafeWhere($"Tags match '\"{tag}\"'"); + } + return q; + } + + public static SqlExpression WhereSearch(this SqlExpression q, string? search, int? skip, int take) { if (!string.IsNullOrEmpty(search)) { - search = search.Trim(); - if (search.StartsWith('[') && search.EndsWith(']')) - { - q.WhereContainsTag(search.TrimStart('[').TrimEnd(']')); - } - else - { - var sb = StringBuilderCache.Allocate(); - var words = search.Split(' '); - for (var i = 0; i < words.Length; i++) - { - if (sb.Length > 0) - sb.Append(" AND "); - sb.AppendLine("(title like '%' || {" + i + "} || '%' or summary like '%' || {" + i + "} || '%' or tags like '%' || {" + i + "} || '%')"); - } - - var sql = StringBuilderCache.ReturnAndFree(sb); - q.UnsafeWhere(sql, words.Cast().ToArray()); - } } return q; } diff --git a/MyApp.ServiceInterface/Data/IdFiles.cs b/MyApp.ServiceInterface/Data/IdFiles.cs deleted file mode 100644 index 29b8bad..0000000 --- a/MyApp.ServiceInterface/Data/IdFiles.cs +++ /dev/null @@ -1,24 +0,0 @@ -using System.Collections.Concurrent; -using ServiceStack.IO; - -namespace MyApp.Data; - -public class IdFiles(int id, string dir1, string dir2, string fileId, List files) -{ - public int Id { get; init; } = id; - public string Dir1 { get; init; } = dir1; - public string Dir2 { get; init; } = dir2; - public string FileId { get; init; } = fileId; - public List Files { get; init; } = files; - public ConcurrentDictionary FileContents { get; } = []; - - public async Task LoadContentsAsync() - { - if (FileContents.Count > 0) return; - var tasks = new List(); - tasks.AddRange(Files.Select(async file => { - FileContents[file.Name] = await file.ReadAllTextAsync(); - })); - await Task.WhenAll(tasks); - } -} \ No newline at end of file diff --git a/MyApp.ServiceInterface/Data/QuestionFiles.cs b/MyApp.ServiceInterface/Data/QuestionFiles.cs new file mode 100644 index 0000000..1d84dfa --- /dev/null +++ b/MyApp.ServiceInterface/Data/QuestionFiles.cs @@ -0,0 +1,101 @@ +using System.Collections.Concurrent; +using MyApp.ServiceModel; +using ServiceStack; +using ServiceStack.IO; + +namespace MyApp.Data; + +public class QuestionFiles(int id, string dir1, string dir2, string fileId, List files, bool remote=false) +{ + public const int MostVotedScore = 10; + public const int AcceptedScore = 9; + public static Dictionary ModelScores = new() + { + ["phi"] = 1, //2.7B + ["gemma:2b"] = 2, + ["qwen:4b"] = 3, //4B + ["codellama"] = 4, //7B + ["gemma"] = 5, //7B + ["deepseek-coder:6.7b"] = 5, //6.7B + ["mistral"] = 7, //7B + ["mixtral"] = 8, //47B + ["accepted"] = 9, + ["most-voted"] = 10, + }; + + public int Id { get; init; } = id; + public string Dir1 { get; init; } = dir1; + public string Dir2 { get; init; } = dir2; + public string DirPath = "/{Dir1}/{Dir2}"; + public string FileId { get; init; } = fileId; + public List Files { get; init; } = files; + public bool LoadedRemotely { get; set; } = remote; + public ConcurrentDictionary FileContents { get; } = []; + public QuestionAndAnswers? Question { get; set; } + + public async Task GetQuestionAsync() + { + if (Question == null) + { + await LoadQuestionAndAnswersAsync(); + } + return Question; + } + + public async Task LoadContentsAsync() + { + if (FileContents.Count > 0) return; + var tasks = new List(); + tasks.AddRange(Files.Select(async file => { + FileContents[file.VirtualPath] = await file.ReadAllTextAsync(); + })); + await Task.WhenAll(tasks); + } + + public async Task LoadQuestionAndAnswersAsync() + { + var questionFileName = FileId + ".json"; + await LoadContentsAsync(); + + var to = new QuestionAndAnswers(); + foreach (var entry in FileContents) + { + var fileName = entry.Key.LastRightPart('/'); + if (fileName == questionFileName) + { + to.Post = entry.Value.FromJson(); + } + else if (fileName.StartsWith(FileId + ".a.")) + { + to.Answers.Add(entry.Value.FromJson()); + } + else if (fileName.StartsWith(FileId + ".h.")) + { + var post = entry.Value.FromJson(); + var userName = fileName.Substring((FileId + ".h.").Length).LeftPart('.'); + var answer = new Answer + { + Id = $"{post.Id}", + Model = userName, + UpVotes = userName == "most-voted" ? MostVotedScore : AcceptedScore, + Choices = [ + new() + { + Index = 1, + Message = new() { Role = userName, Content = post.Body ?? "" } + } + ] + }; + if (to.Answers.All(x => x.Id != answer.Id)) + to.Answers.Add(answer); + } + } + + if (to.Post == null) + return; + + to.Answers.Each(x => x.UpVotes = x.UpVotes == 0 ? ModelScores.GetValueOrDefault(x.Model, 1) : x.UpVotes); + to.Answers.Sort((a, b) => b.Votes - a.Votes); + Question = to; + } +} diff --git a/MyApp.ServiceInterface/Data/QuestionsProvider.cs b/MyApp.ServiceInterface/Data/QuestionsProvider.cs new file mode 100644 index 0000000..f816918 --- /dev/null +++ b/MyApp.ServiceInterface/Data/QuestionsProvider.cs @@ -0,0 +1,64 @@ +using Microsoft.Extensions.Logging; +using ServiceStack; +using ServiceStack.IO; +using ServiceStack.Messaging; + +namespace MyApp.Data; + +public class QuestionsProvider(ILogger log, IMessageProducer mqClient, IVirtualFiles fs, R2VirtualFiles r2) +{ + public QuestionFiles GetLocalQuestionFiles(int id) + { + var (dir1, dir2, fileId) = id.ToFileParts(); + + var files = fs.GetDirectory($"{dir1}/{dir2}").GetAllMatchingFiles($"{fileId}.*") + .OrderByDescending(x => x.LastModified) + .ToList(); + + return new QuestionFiles(id: id, dir1: dir1, dir2: dir2, fileId: fileId, files: files); + } + + public async Task GetRemoteQuestionFilesAsync(int id) + { + var (dir1, dir2, fileId) = id.ToFileParts(); + + var files = (await r2.EnumerateFilesAsync($"{dir1}/{dir2}").ToListAsync()) + .Where(x => x.Name.Glob($"{fileId}.*")) + .OrderByDescending(x => x.LastModified) + .Cast() + .ToList(); + + return new QuestionFiles(id: id, dir1: dir1, dir2: dir2, fileId: fileId, files: files, remote:true); + } + + public async Task GetQuestionFilesAsync(int id) + { + var localFiles = GetLocalQuestionFiles(id); + if (localFiles.Files.Count > 0) + return localFiles; + + log.LogInformation("No local cached files for question {Id}, fetching from R2...", id); + var r = await GetRemoteQuestionFilesAsync(id); + if (r.Files.Count > 0) + { + var lastModified = r.Files.Max(x => x.LastModified); + log.LogInformation("Fetched {Count} files from R2 for question {Id}, last modified: '{LastModified}'", r.Files.Count, id, lastModified); + } + return r; + } + + public async Task GetQuestionAsync(int id) + { + var questionFiles = await GetQuestionFilesAsync(id); + await questionFiles.GetQuestionAsync(); + if (questionFiles.LoadedRemotely) + { + log.LogInformation("Caching question {Id}'s {Count} remote files locally...", id, questionFiles.FileContents.Count); + foreach (var entry in questionFiles.FileContents) + { + await fs.WriteFileAsync(entry.Key, entry.Value); + } + } + return questionFiles; + } +} diff --git a/MyApp.ServiceInterface/Data/R2Extensions.cs b/MyApp.ServiceInterface/Data/R2Extensions.cs index c1fe2fe..e9422f2 100644 --- a/MyApp.ServiceInterface/Data/R2Extensions.cs +++ b/MyApp.ServiceInterface/Data/R2Extensions.cs @@ -1,25 +1,7 @@ -using MyApp.ServiceModel; -using ServiceStack; -using ServiceStack.IO; - -namespace MyApp.Data; +namespace MyApp.Data; public static class R2Extensions { - public const int MostVotedScore = 10; - public const int AcceptedScore = 9; - public static Dictionary ModelScores = new() - { - ["starcoder2:3b"] = 1, //3B - ["phi"] = 2, //2.7B - ["gemma:2b"] = 3, - ["gemma"] = 4, //7B - ["codellama"] = 5, //7B - ["mistral"] = 6, //7B - ["starcoder2:15b"] = 7, //15B - ["mixtral"] = 8, //47B - }; - public static (string dir1, string dir2, string fileId) ToFileParts(this int id) { var idStr = $"{id}".PadLeft(9, '0'); @@ -28,62 +10,4 @@ public static (string dir1, string dir2, string fileId) ToFileParts(this int id) var fileId = idStr[6..]; return (dir1, dir2, fileId); } - - public static async Task GetQuestionFilesAsync(this R2VirtualFiles r2, int id) - { - var (dir1, dir2, fileId) = ToFileParts(id); - - var files = (await r2.EnumerateFilesAsync($"{dir1}/{dir2}").ToListAsync()) - .Where(x => x.Name.Glob($"{fileId}.*")) - .OrderByDescending(x => x.LastModified) - .Cast() - .ToList(); - - return new IdFiles(id: id, dir1: dir1, dir2: dir2, fileId: fileId, files: files); - } - - public static async Task ToQuestionAndAnswers(this IdFiles idFiles) - { - var fileName = idFiles.FileId + ".json"; - await idFiles.LoadContentsAsync(); - - var to = new QuestionAndAnswers(); - foreach (var entry in idFiles.FileContents) - { - if (entry.Key == fileName) - { - to.Post = entry.Value.FromJson(); - } - else if (entry.Key.StartsWith(idFiles.FileId + ".a.")) - { - to.Answers.Add(entry.Value.FromJson()); - } - else if (entry.Key.StartsWith(idFiles.FileId + ".h.")) - { - var post = entry.Value.FromJson(); - var answer = new Answer - { - Id = $"{post.Id}", - Model = "human", - UpVotes = entry.Key.Contains("h.most-voted") ? MostVotedScore : AcceptedScore, - Choices = [ - new() - { - Index = 1, - Message = new() { Role = "human", Content = post.Body ?? "" } - } - ] - }; - if (to.Answers.All(x => x.Id != answer.Id)) - to.Answers.Add(answer); - } - } - - if (to.Post == null) - return null; - - to.Answers.Each(x => x.UpVotes = x.UpVotes == 0 ? ModelScores.GetValueOrDefault(x.Model, 1) : x.UpVotes); - to.Answers.Sort((a, b) => b.Votes - a.Votes); - return to; - } } diff --git a/MyApp.ServiceInterface/Data/StatUtils.cs b/MyApp.ServiceInterface/Data/StatUtils.cs new file mode 100644 index 0000000..24ad2f5 --- /dev/null +++ b/MyApp.ServiceInterface/Data/StatUtils.cs @@ -0,0 +1,16 @@ +using Microsoft.AspNetCore.Http; +using MyApp.ServiceModel; + +namespace MyApp.Data; + +public static class StatUtils +{ + public static T WithRequest(this T stat, HttpContext? ctx) where T : StatBase + { + var user = ctx?.User; + stat.UserName = user?.Identity?.Name; + stat.RemoteIp = ctx?.Connection.RemoteIpAddress?.ToString(); + stat.CreatedDate = DateTime.UtcNow; + return stat; + } +} diff --git a/MyApp.ServiceModel/Icons.cs b/MyApp.ServiceModel/Icons.cs index e01cbeb..abf8110 100644 --- a/MyApp.ServiceModel/Icons.cs +++ b/MyApp.ServiceModel/Icons.cs @@ -3,6 +3,6 @@ namespace MyApp.ServiceModel; public static class Icons { public const string Dashboard = ""; - public const string Booking = ""; - public const string Coupon = ""; + public const string Post = ""; + public const string Stats = ""; } \ No newline at end of file diff --git a/MyApp.ServiceModel/Job.cs b/MyApp.ServiceModel/Job.cs new file mode 100644 index 0000000..af87185 --- /dev/null +++ b/MyApp.ServiceModel/Job.cs @@ -0,0 +1,25 @@ +using ServiceStack.DataAnnotations; + +namespace MyApp.ServiceModel; + +public class Job +{ + [AutoIncrement] + public int Id { get; set; } + + public int PostId { get; set; } + + public string Model { get; set; } + + public DateTime CreatedDate { get; set; } + + public DateTime? StartedDate { get; set; } + + public string? WorkerId { get; set; } + + public string? WorkerIp { get; set; } + + public DateTime? CompletedDate { get; set; } + + public string? Response { get; set; } +} diff --git a/MyApp.ServiceModel/Meta.cs b/MyApp.ServiceModel/Meta.cs new file mode 100644 index 0000000..595760c --- /dev/null +++ b/MyApp.ServiceModel/Meta.cs @@ -0,0 +1,6 @@ +namespace MyApp.ServiceModel; + +public class Meta +{ + public Dictionary ModelVotes { get; set; } +} diff --git a/MyApp.ServiceModel/Posts.cs b/MyApp.ServiceModel/Posts.cs index 3804f11..9a31d06 100644 --- a/MyApp.ServiceModel/Posts.cs +++ b/MyApp.ServiceModel/Posts.cs @@ -6,7 +6,7 @@ namespace MyApp.ServiceModel; -[Icon(Svg = Icons.Booking)] +[Icon(Svg = Icons.Post)] [Description("StackOverflow Question")] [Notes("A StackOverflow Question Post")] public class Post @@ -52,6 +52,16 @@ public class Post public class QueryPosts : QueryDb {} +public class PostFts +{ + [Alias("rowid")] + public int Id { get; set; } + public string RefId { get; set; } + public string UserName { get; set; } + public string Body { get; set; } + public string? Tags { get; set; } +} + public class Choice { public int Index { get; set; } diff --git a/MyApp.ServiceModel/Stats.cs b/MyApp.ServiceModel/Stats.cs new file mode 100644 index 0000000..eb771f8 --- /dev/null +++ b/MyApp.ServiceModel/Stats.cs @@ -0,0 +1,52 @@ +using ServiceStack; +using ServiceStack.DataAnnotations; + +namespace MyApp.ServiceModel; + +public static class Stats +{ + public static bool IsAdminOrModerator(string? userName) => + userName is "admin" or "mythz" or "layoric"; +} + +public static class Databases +{ + // Keep heavy writes of stats + analytics in separate DB + public const string Analytics = nameof(Analytics); + public const string Search = nameof(Search); +} + +[NamedConnection(Databases.Analytics)] +public class StatBase +{ + public string RefId { get; set; } + public string? UserName { get; set; } + public string? RemoteIp { get; set; } + public DateTime CreatedDate { get; set; } +} + +[Icon(Svg = Icons.Stats)] +public class PostStat : StatBase +{ + [AutoIncrement] + public int Id { get; set; } + public int PostId { get; set; } +} + +[Icon(Svg = Icons.Stats)] +public class SearchStat : StatBase +{ + [AutoIncrement] + public int Id { get; set; } + public string? Query { get; set; } +} + +[Tag(Tag.Tasks)] +[ExcludeMetadata] +[Restrict(InternalOnly = true)] +public class AnalyticsTasks +{ + public SearchStat? RecordSearchStat { get; set; } + public PostStat? RecordPostStat { get; set; } +} + diff --git a/MyApp.ServiceModel/Votes.cs b/MyApp.ServiceModel/Votes.cs new file mode 100644 index 0000000..1d5489e --- /dev/null +++ b/MyApp.ServiceModel/Votes.cs @@ -0,0 +1,19 @@ +using ServiceStack.DataAnnotations; + +namespace MyApp.ServiceModel; + +[UniqueConstraint(nameof(UserId), nameof(AnswerId))] +public class Vote +{ + [AutoIncrement] + public int Id { get; set; } + + public int UserId { get; set; } + + public int PostId { get; set; } + + [Required] + public string AnswerId { get; set; } + + public int Score { get; set; } +} diff --git a/MyApp.Tests/UnitTest.cs b/MyApp.Tests/UnitTest.cs index d1bc315..57078af 100644 --- a/MyApp.Tests/UnitTest.cs +++ b/MyApp.Tests/UnitTest.cs @@ -28,4 +28,4 @@ public void Can_call_MyServices() Assert.That(response.Result, Is.EqualTo("Hello, World!")); } -} \ No newline at end of file +} diff --git a/MyApp/Components/Pages/Questions/Index.razor b/MyApp/Components/Pages/Questions/Index.razor index 37ea843..30633e5 100644 --- a/MyApp/Components/Pages/Questions/Index.razor +++ b/MyApp/Components/Pages/Questions/Index.razor @@ -1,5 +1,6 @@ @page "/questions" @inject IDbConnectionFactory DbFactory +@inject IMessageProducer MessageProducer @Title
@@ -58,6 +59,9 @@
@code { + [CascadingParameter] + public HttpContext? HttpContext { get; set; } + string Path => "/questions".AddQueryParam("q", Q); int? Skip => Page > 1 ? (Page - 1) * PageSize : 0; string Title => "All Questions" + (!string.IsNullOrEmpty(Q) ? $" with '{Q}'" : ""); @@ -80,26 +84,86 @@ { try { + if (Q != null) + { + MessageProducer.Publish(new AnalyticsTasks + { + RecordSearchStat = new SearchStat { Query = Q }.WithRequest(HttpContext) + }); + } + if (Tab == null || !Tabs.Contains(Tab)) Tab = Tabs[0]; if (PageSize is null or <= 0) PageSize = 25; - using var db = await DbFactory.OpenAsync(); - var q = db.From(); + var skip = Page > 1 ? (Page - 1) * PageSize : 0; + var take = PageSize.ToPageSize(); - q.OrderByView(Tab); if (!string.IsNullOrEmpty(Q)) { - q.WhereSearch(Q); - } + using var dbSearch = await DbFactory.OpenAsync(Databases.Search); + var q = dbSearch.From(); + + var search = Q.Trim(); + if (search.StartsWith('[') && search.EndsWith(']')) + { + q.WhereContainsTag(search.TrimStart('[').TrimEnd(']')); + } + else + { + q.Where("Body match {0}", search); + } - posts = await db.SelectAsync(q - .OrderByView(Tab) - .Skip(Page > 1 ? (Page - 1) * PageSize : 0) - .Take(PageSize.ToPageSize())); + List postsFts = await dbSearch.SelectAsync(q + .Select("RefId, substring(Body,0,400) as Body") + .OrderBy("Rank") + .Skip(skip) + .Take(take)); - total = db.Count(q); + posts = postsFts.Select(x => new Post + { + Id = x.RefId.LeftPart('-').ToInt(), + PostTypeId = x.RefId.Contains('-') ? 2 : 1, + Summary = x.Body.StripHtml().SubstringWithEllipsis(0,200), + }).ToList(); + + var postIds = posts.Select(x => x.Id).ToSet(); + + using var db = await DbFactory.OpenAsync(); + var fullPosts = await db.SelectAsync(db.From().Where(x => postIds.Contains(x.Id))); + var fullPostsMap = fullPosts.ToDictionary(x => x.Id); + + foreach (var post in posts) + { + if (fullPostsMap.TryGetValue(post.Id, out var fullPost)) + { + post.Title = fullPost.Title; + post.Slug = fullPost.Slug; + if (post.PostTypeId == 1) + { + post.Tags = fullPost.Tags; + post.Score = fullPost.Score; + post.ViewCount = fullPost.ViewCount; + post.CreationDate = fullPost.CreationDate; + } + } + } + + total = dbSearch.Count(q); + } + else + { + using var db = await DbFactory.OpenAsync(); + var q = db.From(); + + posts = await db.SelectAsync(q + .OrderByView(Tab) + .Skip(skip) + .Take(take)); + + total = db.Count(q); + } } catch (Exception ex) { @@ -108,6 +172,4 @@ } protected override Task OnInitializedAsync() => Load(); - - protected override Task OnParametersSetAsync() => Load(); } \ No newline at end of file diff --git a/MyApp/Components/Pages/Questions/Question.razor b/MyApp/Components/Pages/Questions/Question.razor index a99ba13..4be467b 100644 --- a/MyApp/Components/Pages/Questions/Question.razor +++ b/MyApp/Components/Pages/Questions/Question.razor @@ -1,4 +1,5 @@ @page "/questions/{Id:int}/{*Slug}" +@inject QuestionsProvider QuestionsProvider @inject R2VirtualFiles R2 @inject RendererCache RendererCache @inject NavigationManager NavigationManager @@ -34,6 +35,9 @@ @code { + [CascadingParameter] + public HttpContext? HttpContext { get; set; } + [Parameter] public required int Id { get; set; } [Parameter] public required string Slug { get; set; } @@ -47,6 +51,10 @@ async Task load() { + MessageProducer.Publish(new AnalyticsTasks { + RecordPostStat = new PostStat { PostId = Id }.WithRequest(HttpContext) + }); + title = Slug.Replace("-", " ").ToTitleCase(); Html = await RendererCache.GetQuestionPostHtmlAsync(Id); @@ -74,9 +82,9 @@ }); return; } - - var questionFiles = await R2.GetQuestionFilesAsync(Id); - question = await questionFiles.ToQuestionAndAnswers(); + + var questionFiles = await QuestionsProvider.GetQuestionAsync(Id); + question = questionFiles.Question; if (question?.Post?.Body != null) { title = question.Post.Title; @@ -110,6 +118,4 @@ } protected override Task OnInitializedAsync() => load(); - - protected override Task OnParametersSetAsync() => load(); } \ No newline at end of file diff --git a/MyApp/Components/Pages/Questions/Tagged.razor b/MyApp/Components/Pages/Questions/Tagged.razor index f4f142a..7dc4239 100644 --- a/MyApp/Components/Pages/Questions/Tagged.razor +++ b/MyApp/Components/Pages/Questions/Tagged.razor @@ -100,6 +100,4 @@ } protected override Task OnInitializedAsync() => Load(); - - protected override Task OnParametersSetAsync() => Load(); } \ No newline at end of file diff --git a/MyApp/Components/Shared/QuestionPosts.razor b/MyApp/Components/Shared/QuestionPosts.razor index 8ce407c..707a169 100644 --- a/MyApp/Components/Shared/QuestionPosts.razor +++ b/MyApp/Components/Shared/QuestionPosts.razor @@ -3,25 +3,38 @@ @foreach (var post in Posts) {
-