Add news post type with list and detail routes
This commit is contained in:
1 parent
bed2db5972
commit
908a03a1f6
10 files changed
+295
-31
No files matched your search
@@ -0,0 +1,46 @@
|
||||
@page "/beitraege/{Slug}"
|
||||
@using System.Globalization
|
||||
@using Elternbeirat.Web.Services
|
||||
@inject PostService PostService
|
||||
|
||||
@if (_post is null)
|
||||
{
|
||||
<PageTitle>Nicht gefunden</PageTitle>
|
||||
}
|
||||
else
|
||||
{
|
||||
<PageTitle>@_post.Title</PageTitle>
|
||||
<article>
|
||||
<p class="post-meta">
|
||||
<a href="/beitraege">← 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>
|
||||
}
|
||||
|
||||
@code {
|
||||
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,31 @@
|
||||
@page "/beitraege"
|
||||
@using System.Globalization
|
||||
@using Elternbeirat.Web.Services
|
||||
@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>
|
||||
}
|
||||
|
||||
@code {
|
||||
private static readonly CultureInfo _german = CultureInfo.GetCultureInfo("de-DE");
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
---
|
||||
titel: Neue Sporthalle feierlich eröffnet
|
||||
datum: 2025-10-05
|
||||
---
|
||||
|
||||
# Neue Sporthalle feierlich eröffnet
|
||||
|
||||
Die neue Sporthalle der IGMH ist eröffnet. Schülerinnen, Schüler und Lehrkräfte
|
||||
haben damit deutlich mehr Platz für Sportunterricht und Arbeitsgemeinschaften.
|
||||
|
||||
*TODO: Bericht und Fotos ergänzen.*
|
||||
@@ -0,0 +1,12 @@
|
||||
---
|
||||
titel: Neuer Vorstand des Elternbeirats gewählt
|
||||
datum: 2025-02-26
|
||||
---
|
||||
|
||||
# Neuer Vorstand des Elternbeirats gewählt
|
||||
|
||||
Bei der Vollversammlung hat der Elternbeirat der IGMH einen neuen Vorstand
|
||||
gewählt. Das Team bedankt sich für das entgegengebrachte Vertrauen und freut
|
||||
sich auf die gemeinsame Arbeit im neuen Schuljahr.
|
||||
|
||||
*TODO: Namen und Ämter des neuen Vorstands ergänzen (nach Freigabe).*
|
||||
@@ -7,8 +7,9 @@ var builder = WebApplication.CreateBuilder(args);
|
||||
// Add services to the container.
|
||||
builder.Services.AddRazorComponents();
|
||||
|
||||
// Content is read once at startup and cached -> singleton.
|
||||
// Content is read once at startup and cached -> singletons.
|
||||
builder.Services.AddSingleton<PageService>();
|
||||
builder.Services.AddSingleton<PostService>();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
using Markdig;
|
||||
using Markdig.Extensions.Yaml;
|
||||
using Markdig.Syntax;
|
||||
|
||||
namespace Elternbeirat.Web.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Reads simple <c>key: value</c> pairs from a Markdown file's YAML front
|
||||
/// matter. Kept deliberately minimal (one value per line, no nesting) — this is
|
||||
/// enough for titles and dates. YamlDotNet will take over once we need
|
||||
/// structured metadata. Front-matter keys stay German because the Markdown
|
||||
/// files are edited by German-speaking editors.
|
||||
/// </summary>
|
||||
public static class FrontMatter
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns the value of the given front-matter key, or <c>null</c> if the
|
||||
/// file has no front matter or the key is missing. The key match is
|
||||
/// case-insensitive; surrounding quotes on the value are removed.
|
||||
/// </summary>
|
||||
public static string? Read(string key, MarkdownDocument document, string source)
|
||||
{
|
||||
var block = document.Descendants<YamlFrontMatterBlock>().FirstOrDefault();
|
||||
if (block is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var prefix = key + ":";
|
||||
var frontMatter = source.Substring(block.Span.Start, block.Span.Length);
|
||||
foreach (var line in frontMatter.Split('\n'))
|
||||
{
|
||||
var trimmed = line.Trim();
|
||||
if (trimmed.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return trimmed[prefix.Length..].Trim().Trim('"');
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,4 @@
|
||||
using Markdig;
|
||||
using Markdig.Extensions.Yaml;
|
||||
using Markdig.Syntax;
|
||||
|
||||
namespace Elternbeirat.Web.Services;
|
||||
|
||||
@@ -51,36 +49,9 @@ public sealed class PageService
|
||||
var source = File.ReadAllText(path);
|
||||
|
||||
var document = Markdown.Parse(source, pipeline);
|
||||
var title = ReadTitle(document, source) ?? slug;
|
||||
var title = FrontMatter.Read("titel", document, source) ?? slug;
|
||||
var html = Markdown.ToHtml(source, pipeline);
|
||||
|
||||
return new Page { Slug = slug, Title = title, ContentHtml = html };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pulls the <c>titel:</c> value out of the YAML front matter. Kept
|
||||
/// deliberately simple (a single <c>key: value</c> line) — YamlDotNet will
|
||||
/// take over once we need structured metadata. The key stays German because
|
||||
/// the Markdown files are edited by German-speaking editors.
|
||||
/// </summary>
|
||||
private static string? ReadTitle(MarkdownDocument document, string source)
|
||||
{
|
||||
var block = document.Descendants<YamlFrontMatterBlock>().FirstOrDefault();
|
||||
if (block is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var frontMatter = source.Substring(block.Span.Start, block.Span.Length);
|
||||
foreach (var line in frontMatter.Split('\n'))
|
||||
{
|
||||
var trimmed = line.Trim();
|
||||
if (trimmed.StartsWith("titel:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return trimmed["titel:".Length..].Trim().Trim('"');
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
namespace Elternbeirat.Web.Services;
|
||||
|
||||
/// <summary>
|
||||
/// A news post ("Beitrag"), read from a Markdown file in
|
||||
/// <c>Content/beitraege/</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,86 @@
|
||||
using System.Globalization;
|
||||
using Markdig;
|
||||
|
||||
namespace Elternbeirat.Web.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Reads the news posts from <c>Content/beitraege/*.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/beitraege</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", "beitraege");
|
||||
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("titel", document, source) ?? slug;
|
||||
var date = ParseDate(FrontMatter.Read("datum", 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;
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,37 @@ h1:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* News list on /beitraege */
|
||||
.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,
|
||||
.post-meta time {
|
||||
display: block;
|
||||
color: #777;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.post-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.valid.modified:not([type=checkbox]) {
|
||||
outline: 1px solid #26b050;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user