diff --git a/Elternbeirat.Web.Tests/PocketBaseClientTests.cs b/Elternbeirat.Web.Tests/PocketBaseClientTests.cs index f5b1a87..03ddc45 100644 --- a/Elternbeirat.Web.Tests/PocketBaseClientTests.cs +++ b/Elternbeirat.Web.Tests/PocketBaseClientTests.cs @@ -70,9 +70,10 @@ public sealed class PocketBaseClientTests(PocketBaseFixture pocketBase) var pages = await client.GetPagesAsync(); - // Three pages seeded, one with public=false; the draft must be filtered out. - pages.Select(p => p.Slug).ShouldBe(["home", "contact"]); + // The draft (public=false) must be filtered out, the public ones present. pages.ShouldNotContain(p => p.Slug == "draft"); + pages.ShouldContain(p => p.Slug == "home"); + pages.ShouldContain(p => p.Slug == "contact"); } [Fact] diff --git a/Elternbeirat.Web.Tests/PocketBaseFixture.cs b/Elternbeirat.Web.Tests/PocketBaseFixture.cs index d6b3fe4..7953818 100644 --- a/Elternbeirat.Web.Tests/PocketBaseFixture.cs +++ b/Elternbeirat.Web.Tests/PocketBaseFixture.cs @@ -191,7 +191,7 @@ public sealed class PocketBaseFixture : IAsyncLifetime await CreateCollectionAsync(http, "pages", new object[] { new { name = "title", type = "text", required = true }, - new { name = "body", type = "editor" }, + new { name = "body", type = "text" }, new { name = "location", type = "select", maxSelect = 1, values = new[] { "header", "footer" } }, new { name = "order", type = "number" }, new { name = "slug", type = "text", required = true }, @@ -203,7 +203,7 @@ public sealed class PocketBaseFixture : IAsyncLifetime { new { name = "date", type = "date" }, new { name = "title", type = "text", required = true }, - new { name = "body", type = "editor" }, + new { name = "body", type = "text" }, new { name = "slug", type = "text", required = true }, new { name = "public", type = "bool" }, }); @@ -221,7 +221,7 @@ public sealed class PocketBaseFixture : IAsyncLifetime await CreateCollectionAsync(http, "faqs", new object[] { new { name = "question", type = "text", required = true }, - new { name = "answer", type = "editor" }, + new { name = "answer", type = "text" }, new { name = "topic", type = "select", maxSelect = 1, values = new[] { "mensa", "schliessfach", "elterneuro", "elternarbeit" } }, new { name = "public", type = "bool" }, }); @@ -242,17 +242,54 @@ public sealed class PocketBaseFixture : IAsyncLifetime private static async Task SeedRecordsAsync(HttpClient http) { - // Pages: one header page, one footer page. Both public. + // Pages that the route smoke tests reach through the navigation, the + // footer or the FAQ hub. Header pages carry a location+order; the FAQ + // sub-pages are plain content pages without a menu slot. All public. await CreateRecordAsync(http, "pages", new { title = "Startseite", body = "# Willkommen", location = "header", order = 1, slug = "home", embed = new[] { "posts", "events" }, @public = true, }); await CreateRecordAsync(http, "pages", new + { + title = "Vorstandsteam", body = "# Vorstand", location = "header", + order = 2, slug = "board", embed = Array.Empty(), @public = true, + }); + await CreateRecordAsync(http, "pages", new + { + title = "Förderverein", body = "# Förderverein", location = "header", + order = 3, slug = "patrons", embed = Array.Empty(), @public = true, + }); + await CreateRecordAsync(http, "pages", new + { + title = "FAQ", body = "# Häufige Fragen", location = "header", + order = 4, slug = "faq", embed = Array.Empty(), @public = true, + }); + await CreateRecordAsync(http, "pages", new + { + title = "Downloads", body = "# Downloads", location = "header", + order = 5, slug = "downloads", embed = Array.Empty(), @public = true, + }); + await CreateRecordAsync(http, "pages", new + { + title = "FAQ Mensa", body = "# Mensa", location = "", + order = 0, slug = "faq-mensa", embed = Array.Empty(), @public = true, + }); + await CreateRecordAsync(http, "pages", new { title = "Kontakt", body = "Mail an uns", location = "footer", order = 1, slug = "contact", embed = Array.Empty(), @public = true, }); + await CreateRecordAsync(http, "pages", new + { + title = "Impressum", body = "# Impressum", location = "footer", + order = 2, slug = "imprint", embed = Array.Empty(), @public = true, + }); + await CreateRecordAsync(http, "pages", new + { + title = "Datenschutz", body = "# Datenschutz", location = "footer", + order = 3, slug = "privacy", embed = Array.Empty(), @public = true, + }); // A draft page that must never appear (public=false). await CreateRecordAsync(http, "pages", new { diff --git a/Elternbeirat.Web.Tests/RouteSmokeTests.cs b/Elternbeirat.Web.Tests/RouteSmokeTests.cs index f69f239..5f8a73d 100644 --- a/Elternbeirat.Web.Tests/RouteSmokeTests.cs +++ b/Elternbeirat.Web.Tests/RouteSmokeTests.cs @@ -46,17 +46,14 @@ public sealed class RouteSmokeTests : IDisposable public static TheoryData KnownRoutes => [ "/", - "/vorstandsteam", - "/foerderverein", + "/board", + "/patrons", "/faq", "/faq-mensa", - "/faq-schliessfach", - "/faq-elterneuro", - "/faq-elternarbeit", "/downloads", - "/kontakt", - "/impressum", - "/datenschutz", + "/contact", + "/imprint", + "/privacy", "/posts", "/posts/new-board", "/posts/new-hall", diff --git a/Elternbeirat.Web/Components/Layout/MainLayout.razor b/Elternbeirat.Web/Components/Layout/MainLayout.razor index 6d8f5ac..56d549d 100644 --- a/Elternbeirat.Web/Components/Layout/MainLayout.razor +++ b/Elternbeirat.Web/Components/Layout/MainLayout.razor @@ -6,13 +6,13 @@ Elternbeirat IGMH @@ -25,9 +25,9 @@ diff --git a/Elternbeirat.Web/Features/Home/Home.razor b/Elternbeirat.Web/Features/Home/Home.razor index 48118f4..3cc5e19 100644 --- a/Elternbeirat.Web/Features/Home/Home.razor +++ b/Elternbeirat.Web/Features/Home/Home.razor @@ -1,5 +1,4 @@ @page "/" -@inject PostService PostService Elternbeirat der IGMH diff --git a/Elternbeirat.Web/Features/Home/Home.razor.cs b/Elternbeirat.Web/Features/Home/Home.razor.cs index 8ad1eef..dfc36f9 100644 --- a/Elternbeirat.Web/Features/Home/Home.razor.cs +++ b/Elternbeirat.Web/Features/Home/Home.razor.cs @@ -1,5 +1,7 @@ using System.Globalization; -using Elternbeirat.Web.Services; +using Elternbeirat.Contracts; +using Elternbeirat.PocketBase; +using Microsoft.AspNetCore.Components; namespace Elternbeirat.Web.Features.Home; @@ -7,11 +9,29 @@ public partial class Home { private static readonly CultureInfo German = CultureInfo.GetCultureInfo("de-DE"); + [LoggerMessage(Level = LogLevel.Warning, Message = "Could not load posts for the home teaser from PocketBase.")] + private static partial void LogLoadFailed(ILogger logger, Exception exception); + + [Inject] + private PocketBaseClient PocketBase { get; set; } = default!; + + [Inject] + private ILogger Logger { get; set; } = default!; + private IReadOnlyList _latest = []; - protected override void OnInitialized() + protected override async Task OnInitializedAsync() { - // Show the three most recent posts as a teaser on the home page. - _latest = [.. PostService.All.Take(3)]; + try + { + // Show the three most recent posts as a teaser on the home page. + var posts = await PocketBase.GetPostsAsync(); + _latest = [.. posts.Take(3)]; + } + catch (HttpRequestException ex) + { + // Source unreachable -> show no teaser instead of a 500. + LogLoadFailed(Logger, ex); + } } } diff --git a/Elternbeirat.Web/Features/Pages/ContentPage.razor b/Elternbeirat.Web/Features/Pages/ContentPage.razor index dd09e47..63adb59 100644 --- a/Elternbeirat.Web/Features/Pages/ContentPage.razor +++ b/Elternbeirat.Web/Features/Pages/ContentPage.razor @@ -1,5 +1,4 @@ @page "/{Slug}" -@inject PageService PageService @if (_page is null) { @@ -9,6 +8,6 @@ else { @_page.Title
- @((MarkupString)_page.ContentHtml) + @Body
} diff --git a/Elternbeirat.Web/Features/Pages/ContentPage.razor.cs b/Elternbeirat.Web/Features/Pages/ContentPage.razor.cs index 0d37292..ed809fc 100644 --- a/Elternbeirat.Web/Features/Pages/ContentPage.razor.cs +++ b/Elternbeirat.Web/Features/Pages/ContentPage.razor.cs @@ -1,4 +1,6 @@ -using Elternbeirat.Web.Services; +using Elternbeirat.Contracts; +using Elternbeirat.PocketBase; +using Elternbeirat.Web.Shared; using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Http; @@ -6,23 +8,49 @@ namespace Elternbeirat.Web.Features.Pages; public partial class ContentPage { + [LoggerMessage(Level = LogLevel.Warning, Message = "Could not load page '{slug}' from PocketBase.")] + private static partial void LogLoadFailed(ILogger logger, string slug, Exception exception); + [Parameter] public string Slug { get; set; } = ""; + [Inject] + private PocketBaseClient PocketBase { get; set; } = default!; + + [Inject] + private ILogger Logger { get; set; } = default!; + + [CascadingParameter] + private HttpContext? HttpContext { get; set; } + private Page? _page; - protected override void OnParametersSet() - { - _page = PageService.Find(Slug); + // The page body rendered from Markdown to HTML, ready to emit as a + // MarkupString in the markup. + private MarkupString Body => (MarkupString)Markdown.ToHtml(_page?.Body); - // Unknown slug -> 404, so UseStatusCodePagesWithReExecute serves the - // /not-found page instead of an empty 200 response. + protected override async Task OnParametersSetAsync() + { + try + { + var pages = await PocketBase.GetPagesAsync(); + _page = pages.FirstOrDefault( + p => string.Equals(p.Slug, Slug, StringComparison.OrdinalIgnoreCase)); + } + catch (HttpRequestException ex) + { + // Source unreachable -> treat as "not found" rather than a 500. The + // 404 branch below serves the friendly /not-found page. + LogLoadFailed(Logger, Slug, ex); + _page = null; + } + + // Unknown slug (or unreachable source) -> 404, so + // UseStatusCodePagesWithReExecute serves the /not-found page instead of an + // empty 200 response. if (_page is null && HttpContext is not null) { HttpContext.Response.StatusCode = StatusCodes.Status404NotFound; } } - - [CascadingParameter] - private HttpContext? HttpContext { get; set; } } diff --git a/Elternbeirat.Web/Features/Pages/Page.cs b/Elternbeirat.Web/Features/Pages/Page.cs deleted file mode 100644 index 4b0f451..0000000 --- a/Elternbeirat.Web/Features/Pages/Page.cs +++ /dev/null @@ -1,25 +0,0 @@ -namespace Elternbeirat.Web.Services; - -/// -/// A static content page, read from a Markdown file in Content/pages/. -/// The file name (without extension) is the slug. -/// -public sealed class Page -{ - /// - /// URL identifier of the page, taken from the file name without umlauts - /// (e.g. vorstandsteam). It appears in the route /{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; } - - /// - /// The page body rendered from Markdown to HTML (front matter excluded). - /// - public required string ContentHtml { get; init; } -} diff --git a/Elternbeirat.Web/Features/Pages/PageService.cs b/Elternbeirat.Web/Features/Pages/PageService.cs deleted file mode 100644 index d191887..0000000 --- a/Elternbeirat.Web/Features/Pages/PageService.cs +++ /dev/null @@ -1,57 +0,0 @@ -using Markdig; - -namespace Elternbeirat.Web.Services; - -/// -/// Reads the static content pages from Content/pages/*.md, renders them -/// to HTML once at startup and keeps them in memory. Registered as a singleton -/// because the content lives in the image and does not change at runtime. -/// -public sealed class PageService -{ - private readonly IReadOnlyDictionary _pagesBySlug; - - /// - /// Reads every Markdown page from the Content/pages directory below - /// the application root and caches it. - /// - public PageService(IWebHostEnvironment environment) - { - var pipeline = new MarkdownPipelineBuilder() - .UseYamlFrontMatter() - .Build(); - - var directory = Path.Combine(environment.ContentRootPath, "Content", "pages"); - var pages = new Dictionary(StringComparer.OrdinalIgnoreCase); - - if (Directory.Exists(directory)) - { - foreach (var path in Directory.EnumerateFiles(directory, "*.md")) - { - var page = Read(path, pipeline); - pages[page.Slug] = page; - } - } - - _pagesBySlug = pages; - } - - /// - /// Returns the page for the given slug, or null if there is none. - /// The lookup is case-insensitive. - /// - public Page? Find(string slug) => - _pagesBySlug.GetValueOrDefault(slug); - - private static Page 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 html = Markdown.ToHtml(source, pipeline); - - return new Page { Slug = slug, Title = title, ContentHtml = html }; - } -} diff --git a/Elternbeirat.Web/Features/Posts/Post.cs b/Elternbeirat.Web/Features/Posts/Post.cs deleted file mode 100644 index 8631cd4..0000000 --- a/Elternbeirat.Web/Features/Posts/Post.cs +++ /dev/null @@ -1,33 +0,0 @@ -namespace Elternbeirat.Web.Services; - -/// -/// A news post, read from a Markdown file in Content/posts/. 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/Features/Posts/PostService.cs b/Elternbeirat.Web/Features/Posts/PostService.cs deleted file mode 100644 index 996d8da..0000000 --- a/Elternbeirat.Web/Features/Posts/PostService.cs +++ /dev/null @@ -1,86 +0,0 @@ -using System.Globalization; -using Markdig; - -namespace Elternbeirat.Web.Services; - -/// -/// Reads the news posts from Content/posts/*.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/posts 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", "posts"); - 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("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 }; - } - - /// - /// 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/Program.cs b/Elternbeirat.Web/Program.cs index 3ea5781..49ac70e 100644 --- a/Elternbeirat.Web/Program.cs +++ b/Elternbeirat.Web/Program.cs @@ -2,7 +2,6 @@ using Elternbeirat.Contracts; using Elternbeirat.PocketBase; using Elternbeirat.Web.Components; using Elternbeirat.Web.Features.Events; -using Elternbeirat.Web.Services; using Microsoft.AspNetCore.HttpOverrides; var builder = WebApplication.CreateBuilder(args); @@ -18,10 +17,6 @@ var pocketBaseUrl = builder.Configuration["PocketBase:BaseUrl"] builder.Services.AddHttpClient(client => client.BaseAddress = new Uri(pocketBaseUrl)); -// Content is read once at startup and cached -> singletons. -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); - var app = builder.Build(); // NPM terminates TLS and is the only way to reach the container (no port diff --git a/Elternbeirat.Web/Shared/FrontMatter.cs b/Elternbeirat.Web/Shared/FrontMatter.cs deleted file mode 100644 index 950ddab..0000000 --- a/Elternbeirat.Web/Shared/FrontMatter.cs +++ /dev/null @@ -1,42 +0,0 @@ -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/_Imports.razor b/Elternbeirat.Web/_Imports.razor index ac14ee8..cf7ad57 100644 --- a/Elternbeirat.Web/_Imports.razor +++ b/Elternbeirat.Web/_Imports.razor @@ -7,5 +7,4 @@ @using Microsoft.AspNetCore.Components.Web.Virtualization @using Microsoft.JSInterop @using Elternbeirat.Web -@using Elternbeirat.Web.Services @using Elternbeirat.Web.Shared