Serve pages and home from PocketBase, drop the file-based content layer
This commit is contained in:
1 parent
da87716d97
commit
4136af6417
15 files changed
+117
-285
No files matched your search
@@ -1,5 +1,4 @@
|
||||
@page "/"
|
||||
@inject PostService PostService
|
||||
|
||||
<PageTitle>Elternbeirat der IGMH</PageTitle>
|
||||
|
||||
|
||||
@@ -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<Home> Logger { get; set; } = default!;
|
||||
|
||||
private IReadOnlyList<Post> _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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
@page "/{Slug}"
|
||||
@inject PageService PageService
|
||||
|
||||
@if (_page is null)
|
||||
{
|
||||
@@ -9,6 +8,6 @@ else
|
||||
{
|
||||
<PageTitle>@_page.Title</PageTitle>
|
||||
<article>
|
||||
@((MarkupString)_page.ContentHtml)
|
||||
@Body
|
||||
</article>
|
||||
}
|
||||
@@ -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<ContentPage> 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; }
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
namespace Elternbeirat.Web.Services;
|
||||
|
||||
/// <summary>
|
||||
/// A static content page, read from a Markdown file in <c>Content/pages/</c>.
|
||||
/// The file name (without extension) is the slug.
|
||||
/// </summary>
|
||||
public sealed class Page
|
||||
{
|
||||
/// <summary>
|
||||
/// URL identifier of the page, taken from the file name without umlauts
|
||||
/// (e.g. <c>vorstandsteam</c>). It appears in the route <c>/{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>
|
||||
/// The page body rendered from Markdown to HTML (front matter excluded).
|
||||
/// </summary>
|
||||
public required string ContentHtml { get; init; }
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
using Markdig;
|
||||
|
||||
namespace Elternbeirat.Web.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Reads the static content pages from <c>Content/pages/*.md</c>, 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.
|
||||
/// </summary>
|
||||
public sealed class PageService
|
||||
{
|
||||
private readonly IReadOnlyDictionary<string, Page> _pagesBySlug;
|
||||
|
||||
/// <summary>
|
||||
/// Reads every Markdown page from the <c>Content/pages</c> directory below
|
||||
/// the application root and caches it.
|
||||
/// </summary>
|
||||
public PageService(IWebHostEnvironment environment)
|
||||
{
|
||||
var pipeline = new MarkdownPipelineBuilder()
|
||||
.UseYamlFrontMatter()
|
||||
.Build();
|
||||
|
||||
var directory = Path.Combine(environment.ContentRootPath, "Content", "pages");
|
||||
var pages = new Dictionary<string, Page>(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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the page for the given slug, or <c>null</c> if there is none.
|
||||
/// The lookup is case-insensitive.
|
||||
/// </summary>
|
||||
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 };
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
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; }
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user