Restructure into feature folders with code-behind and scoped CSS

This commit is contained in:
tleininger committed 2026-09-21 22:01:21 +02:00
1 parent 5ba523d34d
commit 9d1f8fe0d4
25 files changed
+250 -203

No files matched your search

+33
View File
@@ -0,0 +1,33 @@
namespace Elternbeirat.Web.Services;
/// <summary>
/// A news post, read from a Markdown file in <c>Content/posts/</c>. Unlike a
/// <see cref="Page"/>, a post has a date and
/// is shown in a chronological list. The file name (without extension) is the
/// slug.
/// </summary>
public sealed class Post
{
/// <summary>
/// URL identifier of the post, taken from the file name without umlauts.
/// It appears in the route <c>/beitraege/{slug}</c>.
/// </summary>
public required string Slug { get; init; }
/// <summary>
/// Display title from the YAML front matter (<c>titel:</c>). Falls back to
/// the slug when no title is set.
/// </summary>
public required string Title { get; init; }
/// <summary>
/// Publication date from the front matter (<c>datum:</c>). Used to sort the
/// list newest first.
/// </summary>
public required DateOnly Date { get; init; }
/// <summary>
/// The post body rendered from Markdown to HTML (front matter excluded).
/// </summary>
public required string ContentHtml { get; init; }
}
@@ -0,0 +1,20 @@
@page "/beitraege/{Slug}"
@inject PostService PostService
@if (_post is null)
{
<PageTitle>Nicht gefunden</PageTitle>
}
else
{
<PageTitle>@_post.Title</PageTitle>
<article>
<p class="post-meta">
<a href="/beitraege">&larr; Alle Beiträge</a>
<time datetime="@_post.Date.ToString("yyyy-MM-dd")">
@_post.Date.ToString("d. MMMM yyyy", German)
</time>
</p>
@((MarkupString)_post.ContentHtml)
</article>
}
@@ -0,0 +1,31 @@
using System.Globalization;
using Elternbeirat.Web.Services;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Http;
namespace Elternbeirat.Web.Features.Posts;
public partial class PostDetail
{
private static readonly CultureInfo German = CultureInfo.GetCultureInfo("de-DE");
[Parameter]
public string Slug { get; set; } = "";
private Post? _post;
protected override void OnParametersSet()
{
_post = PostService.Find(Slug);
// Unknown slug -> 404, so UseStatusCodePagesWithReExecute serves the
// /not-found page instead of an empty 200 response.
if (_post is null && HttpContext is not null)
{
HttpContext.Response.StatusCode = StatusCodes.Status404NotFound;
}
}
[CascadingParameter]
private HttpContext? HttpContext { get; set; }
}
@@ -0,0 +1,13 @@
.post-meta {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 1rem;
margin-bottom: 1rem;
}
.post-meta time {
display: block;
color: #777;
font-size: 0.85rem;
}
@@ -0,0 +1,25 @@
@page "/beitraege"
@inject PostService PostService
<PageTitle>Beiträge</PageTitle>
<h1>Beiträge</h1>
@if (PostService.All.Count == 0)
{
<p>Zurzeit gibt es keine Beiträge.</p>
}
else
{
<ul class="post-list">
@foreach (var post in PostService.All)
{
<li>
<a href="@($"/beitraege/{post.Slug}")">@post.Title</a>
<time datetime="@post.Date.ToString("yyyy-MM-dd")">
@post.Date.ToString("d. MMMM yyyy", German)
</time>
</li>
}
</ul>
}
@@ -0,0 +1,8 @@
using System.Globalization;
namespace Elternbeirat.Web.Features.Posts;
public partial class PostList
{
private static readonly CultureInfo German = CultureInfo.GetCultureInfo("de-DE");
}
@@ -0,0 +1,20 @@
.post-list {
list-style: none;
padding: 0;
}
.post-list li {
padding: 0.75rem 0;
border-bottom: 1px solid #eee;
}
.post-list a {
font-size: 1.1rem;
text-decoration: none;
}
.post-list time {
display: block;
color: #777;
font-size: 0.85rem;
}
@@ -0,0 +1,86 @@
using System.Globalization;
using Markdig;
namespace Elternbeirat.Web.Services;
/// <summary>
/// Reads the news posts from <c>Content/posts/*.md</c>, renders them to HTML
/// once at startup and keeps them in memory, sorted newest first. Registered as
/// a singleton because the content lives in the image and does not change at
/// runtime.
/// </summary>
public sealed class PostService
{
private readonly IReadOnlyList<Post> _posts;
private readonly IReadOnlyDictionary<string, Post> _postsBySlug;
/// <summary>
/// Reads every Markdown post from the <c>Content/posts</c> directory
/// below the application root and caches it.
/// </summary>
public PostService(IWebHostEnvironment environment)
{
var pipeline = new MarkdownPipelineBuilder()
.UseYamlFrontMatter()
.Build();
var directory = Path.Combine(environment.ContentRootPath, "Content", "posts");
var posts = new List<Post>();
if (Directory.Exists(directory))
{
foreach (var path in Directory.EnumerateFiles(directory, "*.md"))
{
posts.Add(Read(path, pipeline));
}
}
// Newest first for the list; a stable slug order breaks date ties.
posts.Sort((a, b) =>
{
var byDate = b.Date.CompareTo(a.Date);
return byDate != 0 ? byDate : string.CompareOrdinal(a.Slug, b.Slug);
});
_posts = posts;
_postsBySlug = posts.ToDictionary(p => p.Slug, StringComparer.OrdinalIgnoreCase);
}
/// <summary>
/// All posts, newest first. Used for the overview list.
/// </summary>
public IReadOnlyList<Post> All => _posts;
/// <summary>
/// Returns the post for the given slug, or <c>null</c> if there is none.
/// The lookup is case-insensitive.
/// </summary>
public Post? Find(string slug) =>
_postsBySlug.GetValueOrDefault(slug);
private static Post Read(string path, MarkdownPipeline pipeline)
{
var slug = Path.GetFileNameWithoutExtension(path);
var source = File.ReadAllText(path);
var document = Markdown.Parse(source, pipeline);
var title = FrontMatter.Read("title", document, source) ?? slug;
var date = ParseDate(FrontMatter.Read("date", document, source));
var html = Markdown.ToHtml(source, pipeline);
return new Post { Slug = slug, Title = title, Date = date, ContentHtml = html };
}
/// <summary>
/// Parses the <c>datum:</c> value as an ISO date (yyyy-MM-dd). Falls back to
/// <see cref="DateOnly.MinValue"/> when the value is missing or malformed,
/// so a single bad file does not crash startup — it just sorts last.
/// </summary>
private static DateOnly ParseDate(string? value)
{
return DateOnly.TryParse(value, CultureInfo.InvariantCulture,
DateTimeStyles.None, out var date)
? date
: DateOnly.MinValue;
}
}