From 908a03a1f661806abffeadd891f6beedefc04e1b Mon Sep 17 00:00:00 2001 From: tleininger Date: Mon, 21 Sep 2026 15:47:12 +0200 Subject: [PATCH] Add news post type with list and detail routes --- .../Components/Pages/PostDetail.razor | 46 ++++++++++ .../Components/Pages/PostList.razor | 31 +++++++ .../beitraege/neue-sporthalle-eroeffnet.md | 11 +++ .../beitraege/neuer-vorstand-gewaehlt.md | 12 +++ Elternbeirat.Web/Program.cs | 3 +- Elternbeirat.Web/Services/FrontMatter.cs | 42 +++++++++ Elternbeirat.Web/Services/PageService.cs | 31 +------ Elternbeirat.Web/Services/Post.cs | 33 +++++++ Elternbeirat.Web/Services/PostService.cs | 86 +++++++++++++++++++ Elternbeirat.Web/wwwroot/app.css | 31 +++++++ 10 files changed, 295 insertions(+), 31 deletions(-) create mode 100644 Elternbeirat.Web/Components/Pages/PostDetail.razor create mode 100644 Elternbeirat.Web/Components/Pages/PostList.razor create mode 100644 Elternbeirat.Web/Content/beitraege/neue-sporthalle-eroeffnet.md create mode 100644 Elternbeirat.Web/Content/beitraege/neuer-vorstand-gewaehlt.md create mode 100644 Elternbeirat.Web/Services/FrontMatter.cs create mode 100644 Elternbeirat.Web/Services/Post.cs create mode 100644 Elternbeirat.Web/Services/PostService.cs diff --git a/Elternbeirat.Web/Components/Pages/PostDetail.razor b/Elternbeirat.Web/Components/Pages/PostDetail.razor new file mode 100644 index 0000000..5e678a7 --- /dev/null +++ b/Elternbeirat.Web/Components/Pages/PostDetail.razor @@ -0,0 +1,46 @@ +@page "/beitraege/{Slug}" +@using System.Globalization +@using Elternbeirat.Web.Services +@inject PostService PostService + +@if (_post is null) +{ + Nicht gefunden +} +else +{ + @_post.Title +
+ + @((MarkupString)_post.ContentHtml) +
+} + +@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; } +} diff --git a/Elternbeirat.Web/Components/Pages/PostList.razor b/Elternbeirat.Web/Components/Pages/PostList.razor new file mode 100644 index 0000000..1b2ee6c --- /dev/null +++ b/Elternbeirat.Web/Components/Pages/PostList.razor @@ -0,0 +1,31 @@ +@page "/beitraege" +@using System.Globalization +@using Elternbeirat.Web.Services +@inject PostService PostService + +Beiträge + +

Beiträge

+ +@if (PostService.All.Count == 0) +{ +

Zurzeit gibt es keine Beiträge.

+} +else +{ + +} + +@code { + private static readonly CultureInfo _german = CultureInfo.GetCultureInfo("de-DE"); +} diff --git a/Elternbeirat.Web/Content/beitraege/neue-sporthalle-eroeffnet.md b/Elternbeirat.Web/Content/beitraege/neue-sporthalle-eroeffnet.md new file mode 100644 index 0000000..1bfe16d --- /dev/null +++ b/Elternbeirat.Web/Content/beitraege/neue-sporthalle-eroeffnet.md @@ -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.* diff --git a/Elternbeirat.Web/Content/beitraege/neuer-vorstand-gewaehlt.md b/Elternbeirat.Web/Content/beitraege/neuer-vorstand-gewaehlt.md new file mode 100644 index 0000000..04948ec --- /dev/null +++ b/Elternbeirat.Web/Content/beitraege/neuer-vorstand-gewaehlt.md @@ -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).* diff --git a/Elternbeirat.Web/Program.cs b/Elternbeirat.Web/Program.cs index 87aebe5..0eb725c 100644 --- a/Elternbeirat.Web/Program.cs +++ b/Elternbeirat.Web/Program.cs @@ -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(); +builder.Services.AddSingleton(); var app = builder.Build(); diff --git a/Elternbeirat.Web/Services/FrontMatter.cs b/Elternbeirat.Web/Services/FrontMatter.cs new file mode 100644 index 0000000..950ddab --- /dev/null +++ b/Elternbeirat.Web/Services/FrontMatter.cs @@ -0,0 +1,42 @@ +using Markdig; +using Markdig.Extensions.Yaml; +using Markdig.Syntax; + +namespace Elternbeirat.Web.Services; + +/// +/// Reads simple key: value 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. +/// +public static class FrontMatter +{ + /// + /// Returns the value of the given front-matter key, or null 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. + /// + public static string? Read(string key, MarkdownDocument document, string source) + { + var block = document.Descendants().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; + } +} diff --git a/Elternbeirat.Web/Services/PageService.cs b/Elternbeirat.Web/Services/PageService.cs index 8183c9d..a1cc490 100644 --- a/Elternbeirat.Web/Services/PageService.cs +++ b/Elternbeirat.Web/Services/PageService.cs @@ -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 }; } - - /// - /// Pulls the titel: value out of the YAML front matter. Kept - /// deliberately simple (a single key: value line) — YamlDotNet will - /// take over once we need structured metadata. The key stays German because - /// the Markdown files are edited by German-speaking editors. - /// - private static string? ReadTitle(MarkdownDocument document, string source) - { - var block = document.Descendants().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; - } } diff --git a/Elternbeirat.Web/Services/Post.cs b/Elternbeirat.Web/Services/Post.cs new file mode 100644 index 0000000..9c36a06 --- /dev/null +++ b/Elternbeirat.Web/Services/Post.cs @@ -0,0 +1,33 @@ +namespace Elternbeirat.Web.Services; + +/// +/// A news post ("Beitrag"), read from a Markdown file in +/// Content/beitraege/. Unlike a , a post has a date and +/// is shown in a chronological list. The file name (without extension) is the +/// slug. +/// +public sealed class Post +{ + /// + /// URL identifier of the post, taken from the file name without umlauts. + /// It appears in the route /beitraege/{slug}. + /// + public required string Slug { get; init; } + + /// + /// Display title from the YAML front matter (titel:). Falls back to + /// the slug when no title is set. + /// + public required string Title { get; init; } + + /// + /// Publication date from the front matter (datum:). Used to sort the + /// list newest first. + /// + public required DateOnly Date { get; init; } + + /// + /// The post body rendered from Markdown to HTML (front matter excluded). + /// + public required string ContentHtml { get; init; } +} diff --git a/Elternbeirat.Web/Services/PostService.cs b/Elternbeirat.Web/Services/PostService.cs new file mode 100644 index 0000000..3aa631e --- /dev/null +++ b/Elternbeirat.Web/Services/PostService.cs @@ -0,0 +1,86 @@ +using System.Globalization; +using Markdig; + +namespace Elternbeirat.Web.Services; + +/// +/// Reads the news posts from Content/beitraege/*.md, 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. +/// +public sealed class PostService +{ + private readonly IReadOnlyList _posts; + private readonly IReadOnlyDictionary _postsBySlug; + + /// + /// Reads every Markdown post from the Content/beitraege directory + /// below the application root and caches it. + /// + public PostService(IWebHostEnvironment environment) + { + var pipeline = new MarkdownPipelineBuilder() + .UseYamlFrontMatter() + .Build(); + + var directory = Path.Combine(environment.ContentRootPath, "Content", "beitraege"); + var posts = new List(); + + 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); + } + + /// + /// All posts, newest first. Used for the overview list. + /// + public IReadOnlyList All => _posts; + + /// + /// Returns the post for the given slug, or null if there is none. + /// The lookup is case-insensitive. + /// + 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 }; + } + + /// + /// Parses the datum: value as an ISO date (yyyy-MM-dd). Falls back to + /// when the value is missing or malformed, + /// so a single bad file does not crash startup — it just sorts last. + /// + private static DateOnly ParseDate(string? value) + { + return DateOnly.TryParse(value, CultureInfo.InvariantCulture, + DateTimeStyles.None, out var date) + ? date + : DateOnly.MinValue; + } +} diff --git a/Elternbeirat.Web/wwwroot/app.css b/Elternbeirat.Web/wwwroot/app.css index 4a2497d..0a3e15d 100644 --- a/Elternbeirat.Web/wwwroot/app.css +++ b/Elternbeirat.Web/wwwroot/app.css @@ -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; }