Skip to content

Commit 246b01e

Browse files
bradygasterCopilot
andcommitted
feat: add Markdown rendering, clickable tag filtering, and feed pagination
Three high-priority features from converged user research feedback: 1. Markdown rendering (Markdig + HtmlSanitizer) - Added MarkdownHelper using Markdig AdvancedExtensions pipeline - Artifact content and comment bodies render as rich HTML instead of raw pre text - All output sanitized via HtmlSanitizer to prevent XSS 2. Clickable tags for filtering - Tags on feed and detail pages are now anchor links to /?tag={name} - IndexModel filters artifacts by tag query parameter (case-insensitive) - Active tag filter shown with clear button in flash banner - Tag filter composes with sort, squad, and page params 3. Feed pagination - Page size 20, replacing hardcoded .Take(50) - Skip/Take with page query parameter, clamped to valid range - Primer CSS pagination controls (Previous / page numbers / Next) - Pagination composes with all existing filter params Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 4930180 commit 246b01e

6 files changed

Lines changed: 149 additions & 6 deletions

File tree

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
using Ganss.Xss;
2+
using Markdig;
3+
4+
namespace SquadPlaces.Web.Helpers;
5+
6+
/// <summary>
7+
/// Converts Markdown to sanitized HTML using Markdig + HtmlSanitizer.
8+
/// </summary>
9+
public static class MarkdownHelper
10+
{
11+
private static readonly MarkdownPipeline Pipeline = new MarkdownPipelineBuilder()
12+
.UseAdvancedExtensions()
13+
.Build();
14+
15+
private static readonly HtmlSanitizer Sanitizer = CreateSanitizer();
16+
17+
private static HtmlSanitizer CreateSanitizer()
18+
{
19+
var sanitizer = new HtmlSanitizer();
20+
sanitizer.AllowedTags.Add("code");
21+
sanitizer.AllowedTags.Add("pre");
22+
sanitizer.AllowedTags.Add("blockquote");
23+
sanitizer.AllowedTags.Add("hr");
24+
sanitizer.AllowedTags.Add("table");
25+
sanitizer.AllowedTags.Add("thead");
26+
sanitizer.AllowedTags.Add("tbody");
27+
sanitizer.AllowedTags.Add("tr");
28+
sanitizer.AllowedTags.Add("th");
29+
sanitizer.AllowedTags.Add("td");
30+
sanitizer.AllowedTags.Add("dl");
31+
sanitizer.AllowedTags.Add("dt");
32+
sanitizer.AllowedTags.Add("dd");
33+
sanitizer.AllowedAttributes.Add("class");
34+
return sanitizer;
35+
}
36+
37+
/// <summary>
38+
/// Renders Markdown to sanitized HTML. Returns empty string for null/empty input.
39+
/// </summary>
40+
public static string ToHtml(string? markdown)
41+
{
42+
if (string.IsNullOrWhiteSpace(markdown))
43+
return string.Empty;
44+
45+
var rawHtml = Markdown.ToHtml(markdown, Pipeline);
46+
return Sanitizer.Sanitize(rawHtml);
47+
}
48+
}

src/SquadPlaces.Web/Pages/Artifacts/Detail.cshtml

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
@page
2+
@using SquadPlaces.Web.Helpers
23
@model SquadPlaces.Web.Pages.Artifacts.DetailModel
34
@{
45
ViewData["Title"] = Model.Artifact?.Title ?? "Artifact";
@@ -32,7 +33,7 @@ else
3233
@if (!string.IsNullOrEmpty(Model.Artifact.Content))
3334
{
3435
<div class="markdown-body p-3" style="background: #0d1117; border: 1px solid #30363d; border-radius: 6px;">
35-
<pre style="white-space: pre-wrap; color: #e6edf3;">@Model.Artifact.Content</pre>
36+
@Html.Raw(MarkdownHelper.ToHtml(Model.Artifact.Content))
3637
</div>
3738
}
3839

@@ -41,7 +42,7 @@ else
4142
<div class="d-flex flex-wrap mt-3">
4243
@foreach (var tag in Model.Artifact.Tags.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
4344
{
44-
<span class="Label Label--secondary mr-1 mb-1">@tag</span>
45+
<a href="/?tag=@Uri.EscapeDataString(tag)" class="Label Label--secondary mr-1 mb-1" style="text-decoration: none;">@tag</a>
4546
}
4647
</div>
4748
}

src/SquadPlaces.Web/Pages/Artifacts/_CommentThread.cshtml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
@using SquadPlaces.Data.Models
2+
@using SquadPlaces.Web.Helpers
23
@model (Comment Comment, Dictionary<Guid, List<Comment>> Replies, Dictionary<Guid, string> SquadNames, int Depth)
34
@{
45
var (comment, replies, squadNames, depth) = Model;
@@ -17,7 +18,7 @@
1718
<span class="color-fg-muted text-small ml-1">↩ reply</span>
1819
}
1920
</div>
20-
<div class="color-fg-default" style="white-space: pre-wrap;">@comment.Body</div>
21+
<div class="color-fg-default markdown-body">@Html.Raw(MarkdownHelper.ToHtml(comment.Body))</div>
2122
@if (!string.IsNullOrEmpty(comment.GifUrl))
2223
{
2324
<div class="mt-2">

src/SquadPlaces.Web/Pages/Index.cshtml

Lines changed: 61 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,16 +21,22 @@
2121
</div>
2222

2323
@{
24-
string BuildFeedUrl(string? sort = null, Guid? squad = null)
24+
string BuildFeedUrl(string? sort = null, Guid? squad = null, string? tag = null, int? page = null)
2525
{
2626
var qs = new List<string>();
2727
var effectiveSort = sort ?? Model.ActiveSort;
2828
var effectiveSquad = squad.HasValue ? squad : Model.SquadFilter;
29+
var effectiveTag = tag ?? Model.Tag;
30+
var effectivePage = page ?? 1;
2931

3032
if (effectiveSort != "latest")
3133
qs.Add($"sort={effectiveSort}");
3234
if (effectiveSquad.HasValue)
3335
qs.Add($"squad={effectiveSquad.Value}");
36+
if (!string.IsNullOrEmpty(effectiveTag))
37+
qs.Add($"tag={Uri.EscapeDataString(effectiveTag)}");
38+
if (effectivePage > 1)
39+
qs.Add($"page={effectivePage}");
3440

3541
return qs.Count > 0 ? $"/?{string.Join("&", qs)}" : "/";
3642
}
@@ -53,6 +59,10 @@
5359
{
5460
<input type="hidden" name="sort" value="@Model.ActiveSort" />
5561
}
62+
@if (!string.IsNullOrEmpty(Model.Tag))
63+
{
64+
<input type="hidden" name="tag" value="@Model.Tag" />
65+
}
5666
<select name="squad" class="form-select form-select-sm" onchange="this.form.submit()">
5767
<option value="">All Squads</option>
5868
@foreach (var s in Model.AllSquads.OrderBy(x => x.Name))
@@ -70,6 +80,14 @@
7080
</form>
7181
</div>
7282

83+
@if (!string.IsNullOrEmpty(Model.Tag))
84+
{
85+
<div class="flash flash-info mb-3 d-flex flex-items-center flex-justify-between">
86+
<span>Filtered by tag: <strong>@Model.Tag</strong></span>
87+
<a href="@BuildFeedUrl(tag: "")" class="btn btn-sm btn-outline">✕ Clear filter</a>
88+
</div>
89+
}
90+
7391
@if (Model.Artifacts.Count == 0)
7492
{
7593
<div class="blankslate">
@@ -100,7 +118,7 @@ else
100118
{
101119
foreach (var tag in artifact.Tags.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
102120
{
103-
<span class="Label Label--secondary mr-1 mb-1">@tag</span>
121+
<a href="@BuildFeedUrl(tag: tag, page: 1)" class="Label Label--secondary mr-1 mb-1" style="text-decoration: none;">@tag</a>
104122
}
105123
}
106124
@{
@@ -113,6 +131,47 @@ else
113131
</div>
114132
}
115133
</div>
134+
135+
@if (Model.TotalPages > 1)
136+
{
137+
<nav class="paginate-container mt-4" aria-label="Pagination">
138+
<div class="pagination">
139+
@if (Model.CurrentPage > 1)
140+
{
141+
<a href="@BuildFeedUrl(page: Model.CurrentPage - 1)" class="previous_page" rel="previous">Previous</a>
142+
}
143+
else
144+
{
145+
<span class="previous_page disabled">Previous</span>
146+
}
147+
148+
@for (var p = 1; p <= Model.TotalPages; p++)
149+
{
150+
if (p == Model.CurrentPage)
151+
{
152+
<em class="current" aria-current="page">@p</em>
153+
}
154+
else if (p == 1 || p == Model.TotalPages || Math.Abs(p - Model.CurrentPage) <= 2)
155+
{
156+
<a href="@BuildFeedUrl(page: p)">@p</a>
157+
}
158+
else if (p == 2 || p == Model.TotalPages - 1)
159+
{
160+
<span class="gap">…</span>
161+
}
162+
}
163+
164+
@if (Model.CurrentPage < Model.TotalPages)
165+
{
166+
<a href="@BuildFeedUrl(page: Model.CurrentPage + 1)" class="next_page" rel="next">Next</a>
167+
}
168+
else
169+
{
170+
<span class="next_page disabled">Next</span>
171+
}
172+
</div>
173+
</nav>
174+
}
116175
}
117176

118177
@functions {

src/SquadPlaces.Web/Pages/Index.cshtml.cs

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,11 @@ public class IndexModel(IBlobStorageService storage) : PageModel
1515
public int TotalSquads { get; set; }
1616
public int TotalComments { get; set; }
1717

18+
// Pagination
19+
public int CurrentPage { get; set; } = 1;
20+
public int TotalPages { get; set; } = 1;
21+
public const int PageSize = 20;
22+
1823
[BindProperty(SupportsGet = true)]
1924
[FromQuery(Name = "sort")]
2025
public string? Sort { get; set; }
@@ -23,10 +28,20 @@ public class IndexModel(IBlobStorageService storage) : PageModel
2328
[FromQuery(Name = "squad")]
2429
public Guid? SquadFilter { get; set; }
2530

31+
[BindProperty(SupportsGet = true)]
32+
[FromQuery(Name = "tag")]
33+
public string? Tag { get; set; }
34+
35+
[BindProperty(SupportsGet = true)]
36+
[FromQuery(Name = "page")]
37+
public new int? Page { get; set; }
38+
2639
public string ActiveSort => string.IsNullOrEmpty(Sort) ? "latest" : Sort;
2740

2841
public async Task OnGetAsync()
2942
{
43+
CurrentPage = Page is > 0 ? Page.Value : 1;
44+
3045
var allSquads = await storage.ListSquadsAsync();
3146
AllSquads = allSquads;
3247
TotalSquads = allSquads.Count;
@@ -36,6 +51,16 @@ public async Task OnGetAsync()
3651
? await storage.ListArtifactsAsync(SquadFilter.Value)
3752
: await storage.ListArtifactsAsync();
3853

54+
// Filter by tag
55+
if (!string.IsNullOrWhiteSpace(Tag))
56+
{
57+
allArtifacts = allArtifacts
58+
.Where(a => !string.IsNullOrEmpty(a.Tags) &&
59+
a.Tags.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
60+
.Any(t => t.Equals(Tag, StringComparison.OrdinalIgnoreCase)))
61+
.ToList();
62+
}
63+
3964
TotalArtifacts = allArtifacts.Count;
4065

4166
// Compute comment counts for visible artifacts
@@ -56,6 +81,13 @@ public async Task OnGetAsync()
5681
_ => allArtifacts.OrderByDescending(a => a.CreatedAt),
5782
};
5883

59-
Artifacts = sorted.Take(50).ToList();
84+
// Pagination
85+
TotalPages = Math.Max(1, (int)Math.Ceiling(TotalArtifacts / (double)PageSize));
86+
CurrentPage = Math.Clamp(CurrentPage, 1, TotalPages);
87+
88+
Artifacts = sorted
89+
.Skip((CurrentPage - 1) * PageSize)
90+
.Take(PageSize)
91+
.ToList();
6092
}
6193
}

src/SquadPlaces.Web/SquadPlaces.Web.csproj

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77

88
<ItemGroup>
99
<PackageReference Include="Aspire.Azure.Storage.Blobs" Version="*" />
10+
<PackageReference Include="HtmlSanitizer" Version="9.0.892" />
11+
<PackageReference Include="Markdig" Version="1.1.1" />
1012
</ItemGroup>
1113

1214
<PropertyGroup>

0 commit comments

Comments
 (0)