Generate navigation and render page embeds from PocketBase
This commit is contained in:
1 parent
6b5d9dda55
commit
3fd3796d73
26 files changed
+544
-114
No files matched your search
@@ -7,12 +7,8 @@
|
||||
<list />
|
||||
</option>
|
||||
<option name="commandLineOptions" value="--build" />
|
||||
<!-- Dev overlay: applied LAST so it wins (host ports, local web build, and
|
||||
the pb_data_dev volume replacing the Unraid bind mount). In Rider the
|
||||
sourceFilePath is merged first and secondarySourceFiles after it, so
|
||||
the overlay must sit in secondarySourceFiles, not the other way round --
|
||||
otherwise the base file overrides it and PocketBase mounts the empty
|
||||
/mnt/user prod path locally. -->
|
||||
<!-- The dev overlay must win over the base, so it is listed here:
|
||||
secondarySourceFiles are merged after sourceFilePath. -->
|
||||
<option name="secondarySourceFiles">
|
||||
<list>
|
||||
<option value="$PROJECT_DIR$/compose.dev.yaml" />
|
||||
@@ -24,7 +20,7 @@
|
||||
<option value="eb-web" />
|
||||
</list>
|
||||
</option>
|
||||
<!-- Base compose (production-like), merged first. -->
|
||||
<!-- Base compose, merged first. -->
|
||||
<option name="sourceFilePath" value="$PROJECT_DIR$/compose.yaml" />
|
||||
<option name="upForceRecreate" value="true" />
|
||||
</settings>
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
using Elternbeirat.Contracts;
|
||||
using Elternbeirat.Web.Features.Layout;
|
||||
|
||||
namespace Elternbeirat.Web.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for the navigation builder. Pure logic, no PocketBase needed.
|
||||
/// </summary>
|
||||
public class NavBuilderTests
|
||||
{
|
||||
private static Page Page(string slug, string location, double order, string title) =>
|
||||
new() { Slug = slug, Location = location, Order = order, Title = title };
|
||||
|
||||
[Fact]
|
||||
public void Header_keeps_only_header_pages_ordered_by_order()
|
||||
{
|
||||
var pages = new[]
|
||||
{
|
||||
Page("board", "header", 2, "Vorstandsteam"),
|
||||
Page("home", "header", 1, "Start"),
|
||||
Page("imprint", "footer", 1, "Impressum"),
|
||||
Page("hidden", "", 1, "Unterseite"),
|
||||
};
|
||||
|
||||
var header = NavBuilder.Header(pages);
|
||||
|
||||
header.Select(link => link.Title).ShouldBe(["Start", "Vorstandsteam"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Footer_keeps_only_footer_pages_ordered_by_order()
|
||||
{
|
||||
var pages = new[]
|
||||
{
|
||||
Page("privacy", "footer", 2, "Datenschutz"),
|
||||
Page("imprint", "footer", 1, "Impressum"),
|
||||
Page("home", "header", 1, "Start"),
|
||||
};
|
||||
|
||||
var footer = NavBuilder.Footer(pages);
|
||||
|
||||
footer.Select(link => link.Title).ShouldBe(["Impressum", "Datenschutz"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Home_page_maps_to_root_every_other_slug_to_its_path()
|
||||
{
|
||||
var pages = new[]
|
||||
{
|
||||
Page("home", "header", 1, "Start"),
|
||||
Page("board", "header", 2, "Vorstandsteam"),
|
||||
};
|
||||
|
||||
var header = NavBuilder.Header(pages);
|
||||
|
||||
header[0].Href.ShouldBe("/");
|
||||
header[1].Href.ShouldBe("/board");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Pages_without_a_location_never_appear_in_a_menu()
|
||||
{
|
||||
var pages = new[] { Page("hidden", "", 1, "Unterseite") };
|
||||
|
||||
NavBuilder.Header(pages).ShouldBeEmpty();
|
||||
NavBuilder.Footer(pages).ShouldBeEmpty();
|
||||
}
|
||||
}
|
||||
@@ -257,8 +257,10 @@ public sealed class PocketBaseFixture : IAsyncLifetime
|
||||
});
|
||||
await CreateRecordAsync(http, "pages", new
|
||||
{
|
||||
// Carries an embed so the ContentPage embed path is covered: /patrons
|
||||
// renders its body plus the FAQ teaser.
|
||||
title = "Förderverein", body = "# Förderverein", location = "header",
|
||||
order = 3, slug = "patrons", embed = Array.Empty<string>(), @public = true,
|
||||
order = 3, slug = "patrons", embed = new[] { "faqs" }, @public = true,
|
||||
});
|
||||
// Nav placeholder for the FAQ list (route /faqs is served by FaqList,
|
||||
// which shadows this page). Slug matches production's "faqs" nav entry.
|
||||
|
||||
@@ -98,4 +98,47 @@ public sealed class RouteSmokeTests : IDisposable
|
||||
html.ShouldContain("Wann gibt es Mittagessen?");
|
||||
html.ShouldContain("Mensa und Mittagessen"); // German topic heading
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Layout_navigation_is_built_from_the_pages()
|
||||
{
|
||||
// The header and footer menus are generated from the "pages" records, not
|
||||
// hard-coded. Assert a header link, a footer link, and that the non-public
|
||||
// draft page never leaks into the menu.
|
||||
var client = _factory.CreateClient();
|
||||
|
||||
var html = await client.GetStringAsync(new Uri("/", UriKind.Relative));
|
||||
|
||||
html.ShouldContain("href=\"/board\""); // header page
|
||||
html.ShouldContain("href=\"/imprint\""); // footer page
|
||||
html.ShouldNotContain("href=\"/draft\""); // public=false, filtered out
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Home_renders_the_embeds_its_page_opts_into()
|
||||
{
|
||||
// The home page's embed field is ["posts","events"], so the root route
|
||||
// renders the posts teaser and the events teaser below the intro.
|
||||
var client = _factory.CreateClient();
|
||||
|
||||
var html = await client.GetStringAsync(new Uri("/", UriKind.Relative));
|
||||
|
||||
html.ShouldContain("Aktuelle Beiträge"); // posts embed heading
|
||||
html.ShouldContain("Neuer Vorstand"); // a seeded post title
|
||||
html.ShouldContain("Kommende Termine"); // events embed heading
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Content_page_renders_the_embed_its_page_opts_into()
|
||||
{
|
||||
// /patrons is a catch-all content page whose embed field is ["faqs"], so it
|
||||
// shows its body plus the FAQ teaser -- proving the ContentPage embed path.
|
||||
var client = _factory.CreateClient();
|
||||
|
||||
var html = await client.GetStringAsync(new Uri("/patrons", UriKind.Relative));
|
||||
|
||||
html.ShouldContain("Förderverein"); // the page body
|
||||
html.ShouldContain("Häufige Fragen"); // faqs embed heading
|
||||
html.ShouldContain("Wann gibt es Mittagessen?"); // a seeded question
|
||||
}
|
||||
}
|
||||
@@ -5,14 +5,10 @@
|
||||
<div class="header-inner">
|
||||
<a class="brand" href="/">Elternbeirat IGMH</a>
|
||||
<nav class="main-nav">
|
||||
<NavLink href="/" Match="NavLinkMatch.All">Start</NavLink>
|
||||
<NavLink href="/board">Vorstandsteam</NavLink>
|
||||
<NavLink href="/patrons">Förderverein</NavLink>
|
||||
<NavLink href="/posts">Beiträge</NavLink>
|
||||
<NavLink href="/events">Termine</NavLink>
|
||||
<NavLink href="/faqs">FAQ</NavLink>
|
||||
<NavLink href="/downloads">Downloads</NavLink>
|
||||
<NavLink href="/contact">Kontakt</NavLink>
|
||||
@foreach (var link in _header)
|
||||
{
|
||||
<NavLink href="@link.Href" Match="@(link.Href == "/" ? NavLinkMatch.All : NavLinkMatch.Prefix)">@link.Title</NavLink>
|
||||
}
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
@@ -25,9 +21,10 @@
|
||||
<div class="footer-inner">
|
||||
<span>Elternbeirat der IGMH</span>
|
||||
<nav class="footer-nav">
|
||||
<a href="/contact">Kontakt</a>
|
||||
<a href="/imprint">Impressum</a>
|
||||
<a href="/privacy">Datenschutz</a>
|
||||
@foreach (var link in _footer)
|
||||
{
|
||||
<a href="@link.Href">@link.Title</a>
|
||||
}
|
||||
</nav>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
using Elternbeirat.PocketBase;
|
||||
using Elternbeirat.Web.Features.Layout;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
|
||||
namespace Elternbeirat.Web.Components.Layout;
|
||||
|
||||
public partial class MainLayout
|
||||
{
|
||||
[LoggerMessage(Level = LogLevel.Warning, Message = "Could not load navigation pages from PocketBase.")]
|
||||
private static partial void LogLoadFailed(ILogger logger, Exception exception);
|
||||
|
||||
[Inject]
|
||||
private PocketBaseClient PocketBase { get; set; } = default!;
|
||||
|
||||
[Inject]
|
||||
private ILogger<MainLayout> Logger { get; set; } = default!;
|
||||
|
||||
private IReadOnlyList<NavLinkItem> _header = [];
|
||||
private IReadOnlyList<NavLinkItem> _footer = [];
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var pages = await PocketBase.GetPagesAsync();
|
||||
_header = NavBuilder.Header(pages);
|
||||
_footer = NavBuilder.Footer(pages);
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
// Source unreachable -> render the chrome with an empty menu instead of
|
||||
// a 500. The brand link still leads home.
|
||||
LogLoadFailed(Logger, ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<section class="embed embed-events">
|
||||
<h2>Kommende Termine</h2>
|
||||
|
||||
@if (_upcoming.Count == 0)
|
||||
{
|
||||
<p>Zurzeit sind keine Termine geplant.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<ul class="event-list">
|
||||
@foreach (var ev in _upcoming)
|
||||
{
|
||||
<li>
|
||||
<time datetime="@ev.Start.ToString("s")">@EventFormat.Range(ev)</time>
|
||||
<span class="event-title">@ev.Title</span>
|
||||
@if (!string.IsNullOrWhiteSpace(ev.Location))
|
||||
{
|
||||
<span class="event-location">@ev.Location</span>
|
||||
}
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
<p><a href="/events">Alle Termine →</a></p>
|
||||
}
|
||||
</section>
|
||||
@@ -0,0 +1,43 @@
|
||||
using Elternbeirat.Contracts;
|
||||
using Elternbeirat.PocketBase;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
|
||||
namespace Elternbeirat.Web.Features.Embeds;
|
||||
|
||||
public partial class EventsEmbed
|
||||
{
|
||||
[LoggerMessage(Level = LogLevel.Warning, Message = "Could not load events for an embed from PocketBase.")]
|
||||
private static partial void LogLoadFailed(ILogger logger, Exception exception);
|
||||
|
||||
[Inject]
|
||||
private PocketBaseClient PocketBase { get; set; } = default!;
|
||||
|
||||
[Inject]
|
||||
private ILogger<EventsEmbed> Logger { get; set; } = default!;
|
||||
|
||||
private IReadOnlyList<Event> _upcoming = [];
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
IReadOnlyList<Event> events;
|
||||
try
|
||||
{
|
||||
events = await PocketBase.GetEventsAsync();
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
// Source unreachable -> show no teaser instead of a 500.
|
||||
LogLoadFailed(Logger, ex);
|
||||
return;
|
||||
}
|
||||
|
||||
// Teaser: the three soonest upcoming events, earliest first. An event
|
||||
// earlier today still counts as upcoming.
|
||||
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||
_upcoming = events
|
||||
.Where(e => DateOnly.FromDateTime(e.Start) >= today)
|
||||
.OrderBy(e => e.Start)
|
||||
.Take(3)
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
.embed-events {
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.event-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.event-list li {
|
||||
padding: 0.75rem 0;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
.event-list time {
|
||||
display: block;
|
||||
color: #777;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.event-title {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.event-location::before {
|
||||
content: " · ";
|
||||
color: #777;
|
||||
}
|
||||
|
||||
.event-location {
|
||||
color: #555;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<section class="embed embed-faqs">
|
||||
<h2>Häufige Fragen</h2>
|
||||
|
||||
@if (_faqs.Count == 0)
|
||||
{
|
||||
<p>Zurzeit gibt es keine Fragen.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
@foreach (var faq in _faqs)
|
||||
{
|
||||
<details class="faq-item">
|
||||
<summary>@faq.Question</summary>
|
||||
<div class="faq-answer">
|
||||
@((MarkupString)Markdown.ToHtml(faq.Answer))
|
||||
</div>
|
||||
</details>
|
||||
}
|
||||
<p><a href="/faqs">Alle Fragen →</a></p>
|
||||
}
|
||||
</section>
|
||||
@@ -0,0 +1,34 @@
|
||||
using Elternbeirat.Contracts;
|
||||
using Elternbeirat.PocketBase;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
|
||||
namespace Elternbeirat.Web.Features.Embeds;
|
||||
|
||||
public partial class FaqsEmbed
|
||||
{
|
||||
[LoggerMessage(Level = LogLevel.Warning, Message = "Could not load FAQs for an embed from PocketBase.")]
|
||||
private static partial void LogLoadFailed(ILogger logger, Exception exception);
|
||||
|
||||
[Inject]
|
||||
private PocketBaseClient PocketBase { get; set; } = default!;
|
||||
|
||||
[Inject]
|
||||
private ILogger<FaqsEmbed> Logger { get; set; } = default!;
|
||||
|
||||
private IReadOnlyList<Faq> _faqs = [];
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
// Teaser: the first three questions, with a link to the full hub.
|
||||
var faqs = await PocketBase.GetFaqsAsync();
|
||||
_faqs = [.. faqs.Take(3)];
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
// Source unreachable -> show no teaser instead of a 500.
|
||||
LogLoadFailed(Logger, ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
.embed-faqs {
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.faq-item {
|
||||
border-bottom: 1px solid #eee;
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
|
||||
.faq-item summary {
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.faq-answer {
|
||||
padding: 0.5rem 0 0.25rem;
|
||||
color: #333;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
@* Renders the dynamic blocks a page opts into via its "embed" field, in the
|
||||
given order. Unknown keys are ignored so a typo in PocketBase can never break
|
||||
a page. Each block loads its own data. *@
|
||||
|
||||
@foreach (var key in Embed)
|
||||
{
|
||||
switch (key)
|
||||
{
|
||||
case "posts":
|
||||
<PostsEmbed />
|
||||
break;
|
||||
case "events":
|
||||
<EventsEmbed />
|
||||
break;
|
||||
case "faqs":
|
||||
<FaqsEmbed />
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@code {
|
||||
/// <summary>The page's embed keys ("posts", "events", "faqs"), in order.</summary>
|
||||
[Parameter]
|
||||
public IReadOnlyList<string> Embed { get; set; } = [];
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<section class="embed embed-posts">
|
||||
<h2>Aktuelle Beiträge</h2>
|
||||
|
||||
@if (_latest.Count == 0)
|
||||
{
|
||||
<p>Zurzeit gibt es keine Beiträge.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<ul class="post-list">
|
||||
@foreach (var post in _latest)
|
||||
{
|
||||
<li>
|
||||
<a href="@($"/posts/{post.Slug}")">@post.Title</a>
|
||||
<time datetime="@post.Date.ToString("yyyy-MM-dd")">
|
||||
@post.Date.ToString("d. MMMM yyyy", German)
|
||||
</time>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
<p><a href="/posts">Alle Beiträge →</a></p>
|
||||
}
|
||||
</section>
|
||||
@@ -0,0 +1,37 @@
|
||||
using System.Globalization;
|
||||
using Elternbeirat.Contracts;
|
||||
using Elternbeirat.PocketBase;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
|
||||
namespace Elternbeirat.Web.Features.Embeds;
|
||||
|
||||
public partial class PostsEmbed
|
||||
{
|
||||
private static readonly CultureInfo German = CultureInfo.GetCultureInfo("de-DE");
|
||||
|
||||
[LoggerMessage(Level = LogLevel.Warning, Message = "Could not load posts for an embed from PocketBase.")]
|
||||
private static partial void LogLoadFailed(ILogger logger, Exception exception);
|
||||
|
||||
[Inject]
|
||||
private PocketBaseClient PocketBase { get; set; } = default!;
|
||||
|
||||
[Inject]
|
||||
private ILogger<PostsEmbed> Logger { get; set; } = default!;
|
||||
|
||||
private IReadOnlyList<Post> _latest = [];
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
// Teaser: the three most recent posts, with a link to the full list.
|
||||
var posts = await PocketBase.GetPostsAsync();
|
||||
_latest = [.. posts.Take(3)];
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
// Source unreachable -> show no teaser instead of a 500.
|
||||
LogLoadFailed(Logger, ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
.embed-posts {
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
@@ -22,7 +22,7 @@ else
|
||||
@foreach (var ev in _upcoming)
|
||||
{
|
||||
<li>
|
||||
<time datetime="@ev.Start.ToString("s")">@Format(ev)</time>
|
||||
<time datetime="@ev.Start.ToString("s")">@EventFormat.Range(ev)</time>
|
||||
<span class="event-title">@ev.Title</span>
|
||||
@if (!string.IsNullOrWhiteSpace(ev.Location))
|
||||
{
|
||||
@@ -44,7 +44,7 @@ else
|
||||
@foreach (var ev in _past)
|
||||
{
|
||||
<li>
|
||||
<time datetime="@ev.Start.ToString("s")">@Format(ev)</time>
|
||||
<time datetime="@ev.Start.ToString("s")">@EventFormat.Range(ev)</time>
|
||||
<span class="event-title">@ev.Title</span>
|
||||
@if (!string.IsNullOrWhiteSpace(ev.Location))
|
||||
{
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
using System.Globalization;
|
||||
using Elternbeirat.Contracts;
|
||||
using Elternbeirat.PocketBase;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
@@ -7,8 +6,6 @@ namespace Elternbeirat.Web.Features.Events;
|
||||
|
||||
public partial class EventList
|
||||
{
|
||||
private static readonly CultureInfo German = CultureInfo.GetCultureInfo("de-DE");
|
||||
|
||||
[LoggerMessage(Level = LogLevel.Warning, Message = "Could not load events from PocketBase.")]
|
||||
private static partial void LogLoadFailed(ILogger logger, Exception exception);
|
||||
|
||||
@@ -48,36 +45,4 @@ public partial class EventList
|
||||
.OrderByDescending(e => e.Start)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
// An event counts as all-day when its start carries no wall-clock time
|
||||
// (00:00); PocketBase has no separate flag.
|
||||
private static bool HasTime(Event ev) => ev.Start.TimeOfDay != TimeSpan.Zero;
|
||||
|
||||
// Formats the entry's date range for display: a single day, a date with a
|
||||
// time, or a span across days.
|
||||
private static string Format(Event ev)
|
||||
{
|
||||
var start = HasTime(ev)
|
||||
? ev.Start.ToString("dddd, d. MMMM yyyy, HH:mm", German) + " Uhr"
|
||||
: ev.Start.ToString("dddd, d. MMMM yyyy", German);
|
||||
|
||||
if (ev.End is not { } end)
|
||||
{
|
||||
return start;
|
||||
}
|
||||
|
||||
// Same day: append just the end time. Different days: append the full
|
||||
// end date.
|
||||
if (end.Date == ev.Start.Date)
|
||||
{
|
||||
return HasTime(ev)
|
||||
? start + "–" + end.ToString("HH:mm", German) + " Uhr"
|
||||
: start;
|
||||
}
|
||||
|
||||
var endText = HasTime(ev)
|
||||
? end.ToString("d. MMMM yyyy, HH:mm", German) + " Uhr"
|
||||
: end.ToString("d. MMMM yyyy", German);
|
||||
return start + " – " + endText;
|
||||
}
|
||||
}
|
||||
@@ -9,26 +9,4 @@
|
||||
Antworten auf häufige Fragen und Unterlagen rund um die Elternarbeit.
|
||||
</p>
|
||||
|
||||
<section class="home-news">
|
||||
<h2>Aktuelle Beiträge</h2>
|
||||
|
||||
@if (_latest.Count == 0)
|
||||
{
|
||||
<p>Zurzeit gibt es keine Beiträge.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<ul class="post-list">
|
||||
@foreach (var post in _latest)
|
||||
{
|
||||
<li>
|
||||
<a href="@($"/posts/{post.Slug}")">@post.Title</a>
|
||||
<time datetime="@post.Date.ToString("yyyy-MM-dd")">
|
||||
@post.Date.ToString("d. MMMM yyyy", German)
|
||||
</time>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
<p><a href="/posts">Alle Beiträge →</a></p>
|
||||
}
|
||||
</section>
|
||||
<PageEmbeds Embed="_embed" />
|
||||
@@ -1,5 +1,3 @@
|
||||
using System.Globalization;
|
||||
using Elternbeirat.Contracts;
|
||||
using Elternbeirat.PocketBase;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
|
||||
@@ -7,9 +5,7 @@ namespace Elternbeirat.Web.Features.Home;
|
||||
|
||||
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.")]
|
||||
[LoggerMessage(Level = LogLevel.Warning, Message = "Could not load the home page from PocketBase.")]
|
||||
private static partial void LogLoadFailed(ILogger logger, Exception exception);
|
||||
|
||||
[Inject]
|
||||
@@ -18,19 +14,22 @@ public partial class Home
|
||||
[Inject]
|
||||
private ILogger<Home> Logger { get; set; } = default!;
|
||||
|
||||
private IReadOnlyList<Post> _latest = [];
|
||||
// Which dynamic blocks the home page shows is editorial: it comes from the
|
||||
// "home" page's embed field in PocketBase, not from hard-coded markup.
|
||||
private IReadOnlyList<string> _embed = [];
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
// Show the three most recent posts as a teaser on the home page.
|
||||
var posts = await PocketBase.GetPostsAsync();
|
||||
_latest = [.. posts.Take(3)];
|
||||
var pages = await PocketBase.GetPagesAsync();
|
||||
var home = pages.FirstOrDefault(
|
||||
p => string.Equals(p.Slug, "home", StringComparison.Ordinal));
|
||||
_embed = home?.Embed ?? [];
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
// Source unreachable -> show no teaser instead of a 500.
|
||||
// Source unreachable -> render the intro without embeds instead of a 500.
|
||||
LogLoadFailed(Logger, ex);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,28 +2,3 @@
|
||||
font-size: 1.1rem;
|
||||
color: #444;
|
||||
}
|
||||
|
||||
.home-news {
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.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,31 @@
|
||||
using Elternbeirat.Contracts;
|
||||
|
||||
namespace Elternbeirat.Web.Features.Layout;
|
||||
|
||||
/// <summary>
|
||||
/// Builds the header and footer navigation from the public pages. Pages carry a
|
||||
/// <see cref="Page.Location"/> ("header"/"footer"/"") and an <see cref="Page.Order"/>;
|
||||
/// pages without a location are sub-pages and never appear in a menu.
|
||||
/// </summary>
|
||||
public static class NavBuilder
|
||||
{
|
||||
/// <summary>Header links, ordered, built from pages with location "header".</summary>
|
||||
public static IReadOnlyList<NavLinkItem> Header(IReadOnlyList<Page> pages) =>
|
||||
Build(pages, "header");
|
||||
|
||||
/// <summary>Footer links, ordered, built from pages with location "footer".</summary>
|
||||
public static IReadOnlyList<NavLinkItem> Footer(IReadOnlyList<Page> pages) =>
|
||||
Build(pages, "footer");
|
||||
|
||||
private static List<NavLinkItem> Build(IReadOnlyList<Page> pages, string location) =>
|
||||
[.. pages
|
||||
.Where(page => string.Equals(page.Location, location, StringComparison.Ordinal))
|
||||
.OrderBy(page => page.Order)
|
||||
.Select(page => new NavLinkItem(HrefFor(page.Slug), page.Title))];
|
||||
|
||||
// The home page owns "/" (served by the Home component); every other slug is
|
||||
// reached at "/{slug}", where either a literal route or the catch-all content
|
||||
// page renders it.
|
||||
private static string HrefFor(string slug) =>
|
||||
string.Equals(slug, "home", StringComparison.Ordinal) ? "/" : $"/{slug}";
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace Elternbeirat.Web.Features.Layout;
|
||||
|
||||
/// <summary>A single navigation entry derived from a page record.</summary>
|
||||
/// <param name="Href">Target URL; the "home" page maps to "/".</param>
|
||||
/// <param name="Title">Link text shown to the visitor.</param>
|
||||
public sealed record NavLinkItem(string Href, string Title);
|
||||
@@ -10,4 +10,5 @@ else
|
||||
<article>
|
||||
@Body
|
||||
</article>
|
||||
<PageEmbeds Embed="_page.Embed" />
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
using System.Globalization;
|
||||
using Elternbeirat.Contracts;
|
||||
|
||||
namespace Elternbeirat.Web.Shared;
|
||||
|
||||
/// <summary>
|
||||
/// Formats an <see cref="Event"/>'s date range for display in German. Shared by
|
||||
/// the full event list and the home-page events embed so both render dates the
|
||||
/// same way.
|
||||
/// </summary>
|
||||
public static class EventFormat
|
||||
{
|
||||
private static readonly CultureInfo German = CultureInfo.GetCultureInfo("de-DE");
|
||||
|
||||
// An event counts as all-day when its start carries no wall-clock time
|
||||
// (00:00); PocketBase has no separate flag.
|
||||
private static bool HasTime(Event ev) => ev.Start.TimeOfDay != TimeSpan.Zero;
|
||||
|
||||
/// <summary>
|
||||
/// Formats the entry's date range: a single day, a date with a time, or a
|
||||
/// span across days.
|
||||
/// </summary>
|
||||
public static string Range(Event ev)
|
||||
{
|
||||
var start = HasTime(ev)
|
||||
? ev.Start.ToString("dddd, d. MMMM yyyy, HH:mm", German) + " Uhr"
|
||||
: ev.Start.ToString("dddd, d. MMMM yyyy", German);
|
||||
|
||||
if (ev.End is not { } end)
|
||||
{
|
||||
return start;
|
||||
}
|
||||
|
||||
// Same day: append just the end time. Different days: append the full
|
||||
// end date.
|
||||
if (end.Date == ev.Start.Date)
|
||||
{
|
||||
return HasTime(ev)
|
||||
? start + "–" + end.ToString("HH:mm", German) + " Uhr"
|
||||
: start;
|
||||
}
|
||||
|
||||
var endText = HasTime(ev)
|
||||
? end.ToString("d. MMMM yyyy, HH:mm", German) + " Uhr"
|
||||
: end.ToString("d. MMMM yyyy", German);
|
||||
return start + " – " + endText;
|
||||
}
|
||||
}
|
||||
@@ -8,3 +8,5 @@
|
||||
@using Microsoft.JSInterop
|
||||
@using Elternbeirat.Web
|
||||
@using Elternbeirat.Web.Shared
|
||||
@using Elternbeirat.Web.Features.Layout
|
||||
@using Elternbeirat.Web.Features.Embeds
|
||||
Reference in new issue
Block a user