Read all content from PocketBase, per request #30
No files matched your search
@@ -7,7 +7,8 @@ namespace Elternbeirat.Web.Tests;
|
|||||||
/// with a known seed (see <see cref="PocketBaseFixture"/>). Because the data is fixed,
|
/// with a known seed (see <see cref="PocketBaseFixture"/>). Because the data is fixed,
|
||||||
/// the tests assert on exact values, and they need no network to the live instance.
|
/// the tests assert on exact values, and they need no network to the live instance.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class PocketBaseClientTests(PocketBaseFixture pocketBase) : IClassFixture<PocketBaseFixture>
|
[Collection(PocketBaseTestGroup.Name)]
|
||||||
|
public sealed class PocketBaseClientTests(PocketBaseFixture pocketBase)
|
||||||
{
|
{
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Events_load_sorted_by_start()
|
public async Task Events_load_sorted_by_start()
|
||||||
|
|||||||
@@ -49,6 +49,14 @@ public sealed class PocketBaseFixture : IAsyncLifetime
|
|||||||
/// <summary>Creates a <see cref="PocketBaseClient"/> pointed at this container.</summary>
|
/// <summary>Creates a <see cref="PocketBaseClient"/> pointed at this container.</summary>
|
||||||
public PocketBaseClient CreateClient() => new(_http);
|
public PocketBaseClient CreateClient() => new(_http);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The base URL the container's PocketBase is reachable at. Set once the
|
||||||
|
/// container is up; used to point the web app's client at this instance in the
|
||||||
|
/// route smoke tests.
|
||||||
|
/// </summary>
|
||||||
|
public Uri BaseUrl => _http.BaseAddress
|
||||||
|
?? throw new InvalidOperationException("PocketBase is not started yet.");
|
||||||
|
|
||||||
public async Task InitializeAsync()
|
public async Task InitializeAsync()
|
||||||
{
|
{
|
||||||
// Clear any leftover from an earlier run that was aborted before DisposeAsync
|
// Clear any leftover from an earlier run that was aborted before DisposeAsync
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
namespace Elternbeirat.Web.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Groups every test that needs the seeded PocketBase container into one xUnit
|
||||||
|
/// collection, so the single <see cref="PocketBaseFixture"/> (one container, one
|
||||||
|
/// seed) is shared across those classes instead of started once per class. The
|
||||||
|
/// test project runs one PocketBase for the whole run under the fixed project
|
||||||
|
/// name <c>eb-test-stack</c>; sharing avoids two classes fighting over it.
|
||||||
|
/// </summary>
|
||||||
|
[CollectionDefinition(Name)]
|
||||||
|
public sealed class PocketBaseTestGroup : ICollectionFixture<PocketBaseFixture>
|
||||||
|
{
|
||||||
|
public const string Name = "PocketBase";
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
using System.Net;
|
using System.Net;
|
||||||
|
using Microsoft.AspNetCore.Hosting;
|
||||||
using Microsoft.AspNetCore.Mvc.Testing;
|
using Microsoft.AspNetCore.Mvc.Testing;
|
||||||
|
|
||||||
namespace Elternbeirat.Web.Tests;
|
namespace Elternbeirat.Web.Tests;
|
||||||
@@ -7,19 +8,40 @@ namespace Elternbeirat.Web.Tests;
|
|||||||
/// Smoke tests: every known route must return 200, unknown routes must return
|
/// Smoke tests: every known route must return 200, unknown routes must return
|
||||||
/// 404. This catches broken content files, renamed slugs or routing regressions
|
/// 404. This catches broken content files, renamed slugs or routing regressions
|
||||||
/// before a deploy.
|
/// before a deploy.
|
||||||
|
/// <para>
|
||||||
|
/// The post routes now read from PocketBase, so the tests point the web app at the
|
||||||
|
/// seeded container from <see cref="PocketBaseFixture"/> (shared via the
|
||||||
|
/// collection) and assert against its known slugs. Requires Docker, like the
|
||||||
|
/// client tests.
|
||||||
|
/// </para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class RouteSmokeTests : IClassFixture<WebApplicationFactory<Program>>
|
[Collection(PocketBaseTestGroup.Name)]
|
||||||
|
public sealed class RouteSmokeTests : IDisposable
|
||||||
{
|
{
|
||||||
|
// WithWebHostBuilder returns a new factory that wraps the base one; both are
|
||||||
|
// disposable, so both are held and disposed to avoid leaking either.
|
||||||
|
private readonly WebApplicationFactory<Program> _baseFactory = new();
|
||||||
private readonly WebApplicationFactory<Program> _factory;
|
private readonly WebApplicationFactory<Program> _factory;
|
||||||
|
|
||||||
public RouteSmokeTests(WebApplicationFactory<Program> factory)
|
public RouteSmokeTests(PocketBaseFixture pocketBase)
|
||||||
{
|
{
|
||||||
_factory = factory;
|
// Point the app's PocketBaseClient at the seeded test container instead of
|
||||||
|
// the value from appsettings. UseSetting (not ConfigureAppConfiguration)
|
||||||
|
// because it wins over appsettings.Development.json, which otherwise pins the
|
||||||
|
// client to localhost:8090 while the test container uses a random port.
|
||||||
|
_factory = _baseFactory.WithWebHostBuilder(builder =>
|
||||||
|
builder.UseSetting("PocketBase:BaseUrl", pocketBase.BaseUrl.ToString()));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_factory.Dispose();
|
||||||
|
_baseFactory.Dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Every route that a visitor can reach through the navigation, the FAQ hub
|
/// Every route that a visitor can reach through the navigation, the FAQ hub
|
||||||
/// or the news section.
|
/// or the news section. The post slugs match the fixture's seed.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static TheoryData<string> KnownRoutes =>
|
public static TheoryData<string> KnownRoutes =>
|
||||||
[
|
[
|
||||||
@@ -35,11 +57,11 @@ public sealed class RouteSmokeTests : IClassFixture<WebApplicationFactory<Progra
|
|||||||
"/kontakt",
|
"/kontakt",
|
||||||
"/impressum",
|
"/impressum",
|
||||||
"/datenschutz",
|
"/datenschutz",
|
||||||
"/beitraege",
|
"/posts",
|
||||||
"/beitraege/neuer-vorstand-gewaehlt",
|
"/posts/new-board",
|
||||||
"/beitraege/neue-sporthalle-eroeffnet",
|
"/posts/new-hall",
|
||||||
"/termine",
|
"/events",
|
||||||
"/termine.ics",
|
"/events.ics",
|
||||||
];
|
];
|
||||||
|
|
||||||
[Theory]
|
[Theory]
|
||||||
@@ -55,7 +77,7 @@ public sealed class RouteSmokeTests : IClassFixture<WebApplicationFactory<Progra
|
|||||||
|
|
||||||
[Theory]
|
[Theory]
|
||||||
[InlineData("/gibt-es-nicht")]
|
[InlineData("/gibt-es-nicht")]
|
||||||
[InlineData("/beitraege/gibt-es-nicht")]
|
[InlineData("/posts/gibt-es-nicht")]
|
||||||
public async Task Unknown_route_returns_404(string route)
|
public async Task Unknown_route_returns_404(string route)
|
||||||
{
|
{
|
||||||
var client = _factory.CreateClient();
|
var client = _factory.CreateClient();
|
||||||
|
|||||||
@@ -8,8 +8,8 @@
|
|||||||
<NavLink href="/" Match="NavLinkMatch.All">Start</NavLink>
|
<NavLink href="/" Match="NavLinkMatch.All">Start</NavLink>
|
||||||
<NavLink href="/vorstandsteam">Vorstandsteam</NavLink>
|
<NavLink href="/vorstandsteam">Vorstandsteam</NavLink>
|
||||||
<NavLink href="/foerderverein">Förderverein</NavLink>
|
<NavLink href="/foerderverein">Förderverein</NavLink>
|
||||||
<NavLink href="/beitraege">Beiträge</NavLink>
|
<NavLink href="/posts">Beiträge</NavLink>
|
||||||
<NavLink href="/termine">Termine</NavLink>
|
<NavLink href="/events">Termine</NavLink>
|
||||||
<NavLink href="/faq">FAQ</NavLink>
|
<NavLink href="/faq">FAQ</NavLink>
|
||||||
<NavLink href="/downloads">Downloads</NavLink>
|
<NavLink href="/downloads">Downloads</NavLink>
|
||||||
<NavLink href="/kontakt">Kontakt</NavLink>
|
<NavLink href="/kontakt">Kontakt</NavLink>
|
||||||
|
|||||||
@@ -22,6 +22,12 @@
|
|||||||
<PackageReference Include="YamlDotNet" />
|
<PackageReference Include="YamlDotNet" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
<!-- The typed PocketBaseClient the app reads its content from. Referencing
|
||||||
|
PocketBase brings in Elternbeirat.Contracts (the DTOs) transitively. -->
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\Elternbeirat.PocketBase\Elternbeirat.PocketBase.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
<!-- Content ships as files inside the image, not in a volume. Copy it to
|
<!-- Content ships as files inside the image, not in a volume. Copy it to
|
||||||
the output directory so the container can find it. -->
|
the output directory so the container can find it. -->
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -1,33 +0,0 @@
|
|||||||
namespace Elternbeirat.Web.Features.Events;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// A single calendar entry (parents' evening, meeting, deadline). Read from
|
|
||||||
/// <c>Content/events.yml</c> at startup. All display text is German because it
|
|
||||||
/// is visitor-facing content.
|
|
||||||
/// </summary>
|
|
||||||
public sealed class Event
|
|
||||||
{
|
|
||||||
/// <summary>Short title shown in the list and the calendar.</summary>
|
|
||||||
public required string Title { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// When the entry starts. A date-only entry (no time) is treated as an
|
|
||||||
/// all-day event; see <see cref="HasTime"/>.
|
|
||||||
/// </summary>
|
|
||||||
public required DateTime Start { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// True when <see cref="Start"/> carries a wall-clock time, false for an
|
|
||||||
/// all-day entry. Drives both the list formatting and the ICS export.
|
|
||||||
/// </summary>
|
|
||||||
public required bool HasTime { get; init; }
|
|
||||||
|
|
||||||
/// <summary>Optional end, for multi-hour or multi-day entries.</summary>
|
|
||||||
public DateTime? End { get; init; }
|
|
||||||
|
|
||||||
/// <summary>Optional location, e.g. "Aula" or "Mensa".</summary>
|
|
||||||
public string? Location { get; init; }
|
|
||||||
|
|
||||||
/// <summary>Optional free-text note shown below the entry.</summary>
|
|
||||||
public string? Note { get; init; }
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
@page "/termine"
|
@page "/events"
|
||||||
@inject EventService EventService
|
|
||||||
|
|
||||||
<PageTitle>Termine</PageTitle>
|
<PageTitle>Termine</PageTitle>
|
||||||
|
|
||||||
@@ -7,20 +6,20 @@
|
|||||||
|
|
||||||
<p class="intro">
|
<p class="intro">
|
||||||
Alle Termine des Elternbeirats auf einen Blick. Sie können den Kalender auch
|
Alle Termine des Elternbeirats auf einen Blick. Sie können den Kalender auch
|
||||||
<a href="/termine.ics">abonnieren</a> und in Ihrer Kalender-App automatisch
|
<a href="/events.ics">abonnieren</a> und in Ihrer Kalender-App automatisch
|
||||||
aktuell halten.
|
aktuell halten.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h2>Kommende Termine</h2>
|
<h2>Kommende Termine</h2>
|
||||||
|
|
||||||
@if (EventService.Upcoming.Count == 0)
|
@if (_upcoming.Count == 0)
|
||||||
{
|
{
|
||||||
<p>Zurzeit sind keine Termine geplant.</p>
|
<p>Zurzeit sind keine Termine geplant.</p>
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<ul class="event-list">
|
<ul class="event-list">
|
||||||
@foreach (var ev in EventService.Upcoming)
|
@foreach (var ev in _upcoming)
|
||||||
{
|
{
|
||||||
<li>
|
<li>
|
||||||
<time datetime="@ev.Start.ToString("s")">@Format(ev)</time>
|
<time datetime="@ev.Start.ToString("s")">@Format(ev)</time>
|
||||||
@@ -38,11 +37,11 @@ else
|
|||||||
</ul>
|
</ul>
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (EventService.Past.Count > 0)
|
@if (_past.Count > 0)
|
||||||
{
|
{
|
||||||
<h2>Vergangene Termine</h2>
|
<h2>Vergangene Termine</h2>
|
||||||
<ul class="event-list event-list-past">
|
<ul class="event-list event-list-past">
|
||||||
@foreach (var ev in EventService.Past)
|
@foreach (var ev in _past)
|
||||||
{
|
{
|
||||||
<li>
|
<li>
|
||||||
<time datetime="@ev.Start.ToString("s")">@Format(ev)</time>
|
<time datetime="@ev.Start.ToString("s")">@Format(ev)</time>
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using Elternbeirat.Web.Services;
|
using Elternbeirat.Contracts;
|
||||||
|
using Elternbeirat.PocketBase;
|
||||||
|
using Microsoft.AspNetCore.Components;
|
||||||
|
|
||||||
namespace Elternbeirat.Web.Features.Events;
|
namespace Elternbeirat.Web.Features.Events;
|
||||||
|
|
||||||
@@ -7,11 +9,55 @@ public partial class EventList
|
|||||||
{
|
{
|
||||||
private static readonly CultureInfo German = CultureInfo.GetCultureInfo("de-DE");
|
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);
|
||||||
|
|
||||||
|
[Inject]
|
||||||
|
private PocketBaseClient PocketBase { get; set; } = default!;
|
||||||
|
|
||||||
|
[Inject]
|
||||||
|
private ILogger<EventList> Logger { get; set; } = default!;
|
||||||
|
|
||||||
|
private IReadOnlyList<Event> _upcoming = [];
|
||||||
|
private IReadOnlyList<Event> _past = [];
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
IReadOnlyList<Event> events;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
events = await PocketBase.GetEventsAsync();
|
||||||
|
}
|
||||||
|
catch (HttpRequestException ex)
|
||||||
|
{
|
||||||
|
// Source unreachable -> show empty sections instead of a 500. The page
|
||||||
|
// stays up; the "keine Termine" note is rendered.
|
||||||
|
LogLoadFailed(Logger, ex);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Split into upcoming and past by date, so an event earlier today still
|
||||||
|
// counts as upcoming. Upcoming earliest first, past most recent first.
|
||||||
|
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||||
|
_upcoming = events
|
||||||
|
.Where(e => DateOnly.FromDateTime(e.Start) >= today)
|
||||||
|
.OrderBy(e => e.Start)
|
||||||
|
.ToList();
|
||||||
|
_past = events
|
||||||
|
.Where(e => DateOnly.FromDateTime(e.Start) < today)
|
||||||
|
.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
|
// Formats the entry's date range for display: a single day, a date with a
|
||||||
// time, or a span across days.
|
// time, or a span across days.
|
||||||
private static string Format(Event ev)
|
private static string Format(Event ev)
|
||||||
{
|
{
|
||||||
var start = ev.HasTime
|
var start = HasTime(ev)
|
||||||
? ev.Start.ToString("dddd, d. MMMM yyyy, HH:mm", German) + " Uhr"
|
? ev.Start.ToString("dddd, d. MMMM yyyy, HH:mm", German) + " Uhr"
|
||||||
: ev.Start.ToString("dddd, d. MMMM yyyy", German);
|
: ev.Start.ToString("dddd, d. MMMM yyyy", German);
|
||||||
|
|
||||||
@@ -24,12 +70,12 @@ public partial class EventList
|
|||||||
// end date.
|
// end date.
|
||||||
if (end.Date == ev.Start.Date)
|
if (end.Date == ev.Start.Date)
|
||||||
{
|
{
|
||||||
return ev.HasTime
|
return HasTime(ev)
|
||||||
? start + "–" + end.ToString("HH:mm", German) + " Uhr"
|
? start + "–" + end.ToString("HH:mm", German) + " Uhr"
|
||||||
: start;
|
: start;
|
||||||
}
|
}
|
||||||
|
|
||||||
var endText = ev.HasTime
|
var endText = HasTime(ev)
|
||||||
? end.ToString("d. MMMM yyyy, HH:mm", German) + " Uhr"
|
? end.ToString("d. MMMM yyyy, HH:mm", German) + " Uhr"
|
||||||
: end.ToString("d. MMMM yyyy", German);
|
: end.ToString("d. MMMM yyyy", German);
|
||||||
return start + " – " + endText;
|
return start + " – " + endText;
|
||||||
|
|||||||
@@ -1,138 +0,0 @@
|
|||||||
using System.Globalization;
|
|
||||||
using Elternbeirat.Web.Services;
|
|
||||||
using YamlDotNet.Serialization;
|
|
||||||
using YamlDotNet.Serialization.NamingConventions;
|
|
||||||
|
|
||||||
namespace Elternbeirat.Web.Features.Events;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Reads the calendar entries from <c>Content/events.yml</c> 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 EventService
|
|
||||||
{
|
|
||||||
private readonly IReadOnlyList<Event> _events;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Reads and parses <c>Content/events.yml</c> below the application root.
|
|
||||||
/// A missing file yields an empty list rather than an error.
|
|
||||||
/// </summary>
|
|
||||||
public EventService(IWebHostEnvironment environment)
|
|
||||||
{
|
|
||||||
var path = Path.Combine(environment.ContentRootPath, "Content", "events.yml");
|
|
||||||
_events = File.Exists(path)
|
|
||||||
? Parse(File.ReadAllText(path))
|
|
||||||
: [];
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Entries that start today or later, earliest first — the upcoming section.
|
|
||||||
/// "Today" is compared by date, so an entry earlier today still counts as
|
|
||||||
/// upcoming.
|
|
||||||
/// </summary>
|
|
||||||
public IReadOnlyList<Event> Upcoming =>
|
|
||||||
_events
|
|
||||||
.Where(e => DateOnly.FromDateTime(e.Start) >= DateOnly.FromDateTime(DateTime.Today))
|
|
||||||
.OrderBy(e => e.Start)
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Entries that already passed, most recent first — the past section shown
|
|
||||||
/// below the upcoming ones.
|
|
||||||
/// </summary>
|
|
||||||
public IReadOnlyList<Event> Past =>
|
|
||||||
_events
|
|
||||||
.Where(e => DateOnly.FromDateTime(e.Start) < DateOnly.FromDateTime(DateTime.Today))
|
|
||||||
.OrderByDescending(e => e.Start)
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
/// <summary>All entries in chronological order, for the ICS export.</summary>
|
|
||||||
public IReadOnlyList<Event> All =>
|
|
||||||
_events.OrderBy(e => e.Start).ToList();
|
|
||||||
|
|
||||||
private static List<Event> Parse(string yaml)
|
|
||||||
{
|
|
||||||
// Keys are lowercase single words (title, start, ...), which the camel
|
|
||||||
// case convention maps onto the PascalCase properties.
|
|
||||||
var deserializer = new DeserializerBuilder()
|
|
||||||
.WithNamingConvention(CamelCaseNamingConvention.Instance)
|
|
||||||
.IgnoreUnmatchedProperties()
|
|
||||||
.Build();
|
|
||||||
|
|
||||||
var raw = deserializer.Deserialize<List<EventEntry>?>(yaml) ?? [];
|
|
||||||
return raw
|
|
||||||
.Where(entry => entry.Title is not null && entry.Start is not null)
|
|
||||||
.Select(ToEvent)
|
|
||||||
.OfType<Event>()
|
|
||||||
.ToList();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Event? ToEvent(EventEntry entry)
|
|
||||||
{
|
|
||||||
if (!TryParseWhen(entry.Start!, out var start, out var hasTime))
|
|
||||||
{
|
|
||||||
// A malformed date drops the entry rather than crashing startup.
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
DateTime? end = null;
|
|
||||||
if (entry.End is not null && TryParseWhen(entry.End, out var parsedEnd, out _))
|
|
||||||
{
|
|
||||||
end = parsedEnd;
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Event
|
|
||||||
{
|
|
||||||
Title = entry.Title!,
|
|
||||||
Start = start,
|
|
||||||
HasTime = hasTime,
|
|
||||||
End = end,
|
|
||||||
Location = entry.Location,
|
|
||||||
Note = entry.Note,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Parses a value that is either a date (<c>2026-03-15</c>) or a date with a
|
|
||||||
/// time (<c>2026-03-15 19:30</c>). Sets <paramref name="hasTime"/> so the
|
|
||||||
/// caller knows whether to treat the entry as all-day.
|
|
||||||
/// </summary>
|
|
||||||
private static bool TryParseWhen(string value, out DateTime when, out bool hasTime)
|
|
||||||
{
|
|
||||||
var trimmed = value.Trim();
|
|
||||||
|
|
||||||
if (DateOnly.TryParseExact(trimmed, "yyyy-MM-dd",
|
|
||||||
CultureInfo.InvariantCulture, DateTimeStyles.None, out var dateOnly))
|
|
||||||
{
|
|
||||||
when = dateOnly.ToDateTime(TimeOnly.MinValue);
|
|
||||||
hasTime = false;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
string[] formats = ["yyyy-MM-dd HH:mm", "yyyy-MM-dd'T'HH:mm"];
|
|
||||||
if (DateTime.TryParseExact(trimmed, formats,
|
|
||||||
CultureInfo.InvariantCulture, DateTimeStyles.None, out when))
|
|
||||||
{
|
|
||||||
hasTime = true;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
when = default;
|
|
||||||
hasTime = false;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Raw shape of one YAML entry, before validation. String-typed on purpose so
|
|
||||||
/// we control date parsing and can distinguish date-only from date-and-time.
|
|
||||||
/// </summary>
|
|
||||||
private sealed class EventEntry
|
|
||||||
{
|
|
||||||
public string? Title { get; set; }
|
|
||||||
public string? Start { get; set; }
|
|
||||||
public string? End { get; set; }
|
|
||||||
public string? Location { get; set; }
|
|
||||||
public string? Note { get; set; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using System.Security.Cryptography;
|
using System.Security.Cryptography;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using Elternbeirat.Web.Services;
|
using Elternbeirat.Contracts;
|
||||||
|
|
||||||
namespace Elternbeirat.Web.Features.Events;
|
namespace Elternbeirat.Web.Features.Events;
|
||||||
|
|
||||||
@@ -45,7 +45,7 @@ public static class IcsCalendar
|
|||||||
// between requests, so caches and clients do not see spurious changes.
|
// between requests, so caches and clients do not see spurious changes.
|
||||||
AppendLine(sb, "DTSTAMP:20000101T000000Z");
|
AppendLine(sb, "DTSTAMP:20000101T000000Z");
|
||||||
|
|
||||||
if (ev.HasTime)
|
if (HasTime(ev))
|
||||||
{
|
{
|
||||||
AppendLine(sb, $"DTSTART:{Local(ev.Start)}");
|
AppendLine(sb, $"DTSTART:{Local(ev.Start)}");
|
||||||
if (ev.End is { } end)
|
if (ev.End is { } end)
|
||||||
@@ -75,6 +75,12 @@ public static class IcsCalendar
|
|||||||
AppendLine(sb, "END:VEVENT");
|
AppendLine(sb, "END:VEVENT");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// An entry counts as all-day when its start has no wall-clock time (00:00).
|
||||||
|
/// PocketBase has no separate flag; the convention is a midnight start.
|
||||||
|
/// </summary>
|
||||||
|
private static bool HasTime(Event ev) => ev.Start.TimeOfDay != TimeSpan.Zero;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// A UID that stays the same as long as the entry's title and start do, so a
|
/// A UID that stays the same as long as the entry's title and start do, so a
|
||||||
/// re-subscribe updates the event instead of creating a duplicate.
|
/// re-subscribe updates the event instead of creating a duplicate.
|
||||||
|
|||||||
@@ -23,13 +23,13 @@
|
|||||||
@foreach (var post in _latest)
|
@foreach (var post in _latest)
|
||||||
{
|
{
|
||||||
<li>
|
<li>
|
||||||
<a href="@($"/beitraege/{post.Slug}")">@post.Title</a>
|
<a href="@($"/posts/{post.Slug}")">@post.Title</a>
|
||||||
<time datetime="@post.Date.ToString("yyyy-MM-dd")">
|
<time datetime="@post.Date.ToString("yyyy-MM-dd")">
|
||||||
@post.Date.ToString("d. MMMM yyyy", German)
|
@post.Date.ToString("d. MMMM yyyy", German)
|
||||||
</time>
|
</time>
|
||||||
</li>
|
</li>
|
||||||
}
|
}
|
||||||
</ul>
|
</ul>
|
||||||
<p><a href="/beitraege">Alle Beiträge →</a></p>
|
<p><a href="/posts">Alle Beiträge →</a></p>
|
||||||
}
|
}
|
||||||
</section>
|
</section>
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
@page "/beitraege/{Slug}"
|
@page "/posts/{Slug}"
|
||||||
@inject PostService PostService
|
|
||||||
|
|
||||||
@if (_post is null)
|
@if (_post is null)
|
||||||
{
|
{
|
||||||
@@ -10,11 +9,11 @@ else
|
|||||||
<PageTitle>@_post.Title</PageTitle>
|
<PageTitle>@_post.Title</PageTitle>
|
||||||
<article>
|
<article>
|
||||||
<p class="post-meta">
|
<p class="post-meta">
|
||||||
<a href="/beitraege">← Alle Beiträge</a>
|
<a href="/posts">← Alle Beiträge</a>
|
||||||
<time datetime="@_post.Date.ToString("yyyy-MM-dd")">
|
<time datetime="@_post.Date.ToString("yyyy-MM-dd")">
|
||||||
@_post.Date.ToString("d. MMMM yyyy", German)
|
@_post.Date.ToString("d. MMMM yyyy", German)
|
||||||
</time>
|
</time>
|
||||||
</p>
|
</p>
|
||||||
@((MarkupString)_post.ContentHtml)
|
@Body
|
||||||
</article>
|
</article>
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using Elternbeirat.Web.Services;
|
using Elternbeirat.Contracts;
|
||||||
|
using Elternbeirat.PocketBase;
|
||||||
|
using Elternbeirat.Web.Shared;
|
||||||
using Microsoft.AspNetCore.Components;
|
using Microsoft.AspNetCore.Components;
|
||||||
using Microsoft.AspNetCore.Http;
|
using Microsoft.AspNetCore.Http;
|
||||||
|
|
||||||
@@ -9,23 +11,49 @@ public partial class PostDetail
|
|||||||
{
|
{
|
||||||
private static readonly CultureInfo German = CultureInfo.GetCultureInfo("de-DE");
|
private static readonly CultureInfo German = CultureInfo.GetCultureInfo("de-DE");
|
||||||
|
|
||||||
|
[LoggerMessage(Level = LogLevel.Warning, Message = "Could not load post '{slug}' from PocketBase.")]
|
||||||
|
private static partial void LogLoadFailed(ILogger logger, string slug, Exception exception);
|
||||||
|
|
||||||
[Parameter]
|
[Parameter]
|
||||||
public string Slug { get; set; } = "";
|
public string Slug { get; set; } = "";
|
||||||
|
|
||||||
|
[Inject]
|
||||||
|
private PocketBaseClient PocketBase { get; set; } = default!;
|
||||||
|
|
||||||
|
[Inject]
|
||||||
|
private ILogger<PostDetail> Logger { get; set; } = default!;
|
||||||
|
|
||||||
|
[CascadingParameter]
|
||||||
|
private HttpContext? HttpContext { get; set; }
|
||||||
|
|
||||||
private Post? _post;
|
private Post? _post;
|
||||||
|
|
||||||
protected override void OnParametersSet()
|
// The post body rendered from Markdown to HTML, ready to emit as a
|
||||||
{
|
// MarkupString in the markup.
|
||||||
_post = PostService.Find(Slug);
|
private MarkupString Body => (MarkupString)Markdown.ToHtml(_post?.Body);
|
||||||
|
|
||||||
// Unknown slug -> 404, so UseStatusCodePagesWithReExecute serves the
|
protected override async Task OnParametersSetAsync()
|
||||||
// /not-found page instead of an empty 200 response.
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var posts = await PocketBase.GetPostsAsync();
|
||||||
|
_post = posts.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);
|
||||||
|
_post = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unknown slug (or unreachable source) -> 404, so
|
||||||
|
// UseStatusCodePagesWithReExecute serves the /not-found page instead of an
|
||||||
|
// empty 200 response.
|
||||||
if (_post is null && HttpContext is not null)
|
if (_post is null && HttpContext is not null)
|
||||||
{
|
{
|
||||||
HttpContext.Response.StatusCode = StatusCodes.Status404NotFound;
|
HttpContext.Response.StatusCode = StatusCodes.Status404NotFound;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
[CascadingParameter]
|
|
||||||
private HttpContext? HttpContext { get; set; }
|
|
||||||
}
|
}
|
||||||
@@ -1,21 +1,20 @@
|
|||||||
@page "/beitraege"
|
@page "/posts"
|
||||||
@inject PostService PostService
|
|
||||||
|
|
||||||
<PageTitle>Beiträge</PageTitle>
|
<PageTitle>Beiträge</PageTitle>
|
||||||
|
|
||||||
<h1>Beiträge</h1>
|
<h1>Beiträge</h1>
|
||||||
|
|
||||||
@if (PostService.All.Count == 0)
|
@if (_posts.Count == 0)
|
||||||
{
|
{
|
||||||
<p>Zurzeit gibt es keine Beiträge.</p>
|
<p>Zurzeit gibt es keine Beiträge.</p>
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<ul class="post-list">
|
<ul class="post-list">
|
||||||
@foreach (var post in PostService.All)
|
@foreach (var post in _posts)
|
||||||
{
|
{
|
||||||
<li>
|
<li>
|
||||||
<a href="@($"/beitraege/{post.Slug}")">@post.Title</a>
|
<a href="@($"/posts/{post.Slug}")">@post.Title</a>
|
||||||
<time datetime="@post.Date.ToString("yyyy-MM-dd")">
|
<time datetime="@post.Date.ToString("yyyy-MM-dd")">
|
||||||
@post.Date.ToString("d. MMMM yyyy", German)
|
@post.Date.ToString("d. MMMM yyyy", German)
|
||||||
</time>
|
</time>
|
||||||
|
|||||||
@@ -1,8 +1,36 @@
|
|||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
|
using Elternbeirat.Contracts;
|
||||||
|
using Elternbeirat.PocketBase;
|
||||||
|
using Microsoft.AspNetCore.Components;
|
||||||
|
|
||||||
namespace Elternbeirat.Web.Features.Posts;
|
namespace Elternbeirat.Web.Features.Posts;
|
||||||
|
|
||||||
public partial class PostList
|
public partial class PostList
|
||||||
{
|
{
|
||||||
|
[LoggerMessage(Level = LogLevel.Warning, Message = "Could not load posts from PocketBase.")]
|
||||||
|
private static partial void LogLoadFailed(ILogger logger, Exception exception);
|
||||||
|
|
||||||
private static readonly CultureInfo German = CultureInfo.GetCultureInfo("de-DE");
|
private static readonly CultureInfo German = CultureInfo.GetCultureInfo("de-DE");
|
||||||
|
|
||||||
|
[Inject]
|
||||||
|
private PocketBaseClient PocketBase { get; set; } = default!;
|
||||||
|
|
||||||
|
[Inject]
|
||||||
|
private ILogger<PostList> Logger { get; set; } = default!;
|
||||||
|
|
||||||
|
private IReadOnlyList<Post> _posts = [];
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_posts = await PocketBase.GetPostsAsync();
|
||||||
|
}
|
||||||
|
catch (HttpRequestException ex)
|
||||||
|
{
|
||||||
|
// Source unreachable -> show an empty list instead of a 500. The page
|
||||||
|
// stays up; the "keine Beiträge" note is rendered.
|
||||||
|
LogLoadFailed(Logger, ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using Elternbeirat.Contracts;
|
||||||
|
using Elternbeirat.PocketBase;
|
||||||
using Elternbeirat.Web.Components;
|
using Elternbeirat.Web.Components;
|
||||||
using Elternbeirat.Web.Features.Events;
|
using Elternbeirat.Web.Features.Events;
|
||||||
using Elternbeirat.Web.Services;
|
using Elternbeirat.Web.Services;
|
||||||
@@ -8,10 +10,17 @@ var builder = WebApplication.CreateBuilder(args);
|
|||||||
// Add services to the container.
|
// Add services to the container.
|
||||||
builder.Services.AddRazorComponents();
|
builder.Services.AddRazorComponents();
|
||||||
|
|
||||||
|
// The typed client reads content from PocketBase over its REST API. The base URL
|
||||||
|
// comes from configuration: appsettings for local dev, the PocketBase__BaseUrl
|
||||||
|
// env var on the server (see compose.yaml).
|
||||||
|
var pocketBaseUrl = builder.Configuration["PocketBase:BaseUrl"]
|
||||||
|
?? throw new InvalidOperationException("PocketBase:BaseUrl is not configured.");
|
||||||
|
builder.Services.AddHttpClient<PocketBaseClient>(client =>
|
||||||
|
client.BaseAddress = new Uri(pocketBaseUrl));
|
||||||
|
|
||||||
// Content is read once at startup and cached -> singletons.
|
// Content is read once at startup and cached -> singletons.
|
||||||
builder.Services.AddSingleton<PageService>();
|
builder.Services.AddSingleton<PageService>();
|
||||||
builder.Services.AddSingleton<PostService>();
|
builder.Services.AddSingleton<PostService>();
|
||||||
builder.Services.AddSingleton<EventService>();
|
|
||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
|
||||||
@@ -43,8 +52,21 @@ app.MapStaticAssets();
|
|||||||
app.MapRazorComponents<App>();
|
app.MapRazorComponents<App>();
|
||||||
|
|
||||||
// Subscribable calendar feed of all events. A minimal API endpoint rather than
|
// Subscribable calendar feed of all events. A minimal API endpoint rather than
|
||||||
// a Razor page because it returns text/calendar, not HTML.
|
// a Razor page because it returns text/calendar, not HTML. Reads from PocketBase
|
||||||
app.MapGet("/termine.ics", (EventService events) =>
|
// per request; an unreachable source yields an empty calendar, not a 500.
|
||||||
Results.Text(IcsCalendar.Build(events.All), "text/calendar; charset=utf-8"));
|
app.MapGet("/events.ics", async (PocketBaseClient pocketBase) =>
|
||||||
|
{
|
||||||
|
IReadOnlyList<Event> events;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
events = await pocketBase.GetEventsAsync();
|
||||||
|
}
|
||||||
|
catch (HttpRequestException)
|
||||||
|
{
|
||||||
|
events = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return Results.Text(IcsCalendar.Build(events), "text/calendar; charset=utf-8");
|
||||||
|
});
|
||||||
|
|
||||||
app.Run();
|
app.Run();
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
using Markdig;
|
||||||
|
|
||||||
|
namespace Elternbeirat.Web.Shared;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Renders Markdown to HTML. Content now comes from PocketBase as raw Markdown
|
||||||
|
/// (the <c>body</c> field), so the pages render it at display time instead of
|
||||||
|
/// reading pre-rendered HTML from files.
|
||||||
|
/// </summary>
|
||||||
|
public static class Markdown
|
||||||
|
{
|
||||||
|
private static readonly MarkdownPipeline Pipeline =
|
||||||
|
new MarkdownPipelineBuilder().Build();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Converts a Markdown string to HTML. Returns an empty string for
|
||||||
|
/// <c>null</c> or empty input, so callers can bind the result directly.
|
||||||
|
/// </summary>
|
||||||
|
public static string ToHtml(string? markdown) =>
|
||||||
|
string.IsNullOrEmpty(markdown)
|
||||||
|
? ""
|
||||||
|
: Markdig.Markdown.ToHtml(markdown, Pipeline);
|
||||||
|
}
|
||||||
@@ -8,3 +8,4 @@
|
|||||||
@using Microsoft.JSInterop
|
@using Microsoft.JSInterop
|
||||||
@using Elternbeirat.Web
|
@using Elternbeirat.Web
|
||||||
@using Elternbeirat.Web.Services
|
@using Elternbeirat.Web.Services
|
||||||
|
@using Elternbeirat.Web.Shared
|
||||||
@@ -4,5 +4,8 @@
|
|||||||
"Default": "Information",
|
"Default": "Information",
|
||||||
"Microsoft.AspNetCore": "Warning"
|
"Microsoft.AspNetCore": "Warning"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"PocketBase": {
|
||||||
|
"BaseUrl": "http://localhost:8090"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -5,5 +5,10 @@
|
|||||||
"Microsoft.AspNetCore": "Warning"
|
"Microsoft.AspNetCore": "Warning"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"AllowedHosts": "*"
|
"AllowedHosts": "*",
|
||||||
|
"PocketBase": {
|
||||||
|
// The PocketBase service name on the compose network. compose.yaml overrides
|
||||||
|
// this via the env var PocketBase__BaseUrl; local dev uses appsettings.Development.json.
|
||||||
|
"BaseUrl": "http://eb-pocketbase:8090"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+17
-3
@@ -1,6 +1,10 @@
|
|||||||
# Base compose: production-like. Both services, no host port mappings (NPM is the
|
# Base compose: production-like. Both services; only PocketBase's data is a volume,
|
||||||
# only path to the web app; the editors reach PocketBase through an NPM subdomain).
|
# everything else lives inside the images.
|
||||||
# Only PocketBase's data is a volume; everything else lives inside the images.
|
#
|
||||||
|
# The two `ports:` blocks below are TEMPORARY. The target state is NPM in front:
|
||||||
|
# NPM terminates TLS and is the only path to the web app, and the editors reach
|
||||||
|
# PocketBase through an NPM subdomain -- then both port mappings go away (#9). While
|
||||||
|
# NPM is not set up yet, the ports are the only way onto the site and the admin UI.
|
||||||
#
|
#
|
||||||
# For local development use the dev overlay on top, which adds host ports and builds
|
# For local development use the dev overlay on top, which adds host ports and builds
|
||||||
# the web image from source:
|
# the web image from source:
|
||||||
@@ -25,6 +29,11 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
# The only state that must survive a restart or redeploy.
|
# The only state that must survive a restart or redeploy.
|
||||||
- /mnt/user/appdata/pocketbase/pb_data:/pb_data
|
- /mnt/user/appdata/pocketbase/pb_data:/pb_data
|
||||||
|
ports:
|
||||||
|
# TEMPORARY: exposes the admin UI on the LAN (http://<unraid>:8090/_/) while
|
||||||
|
# NPM is not in front yet. Remove once the editors reach PocketBase through an
|
||||||
|
# NPM subdomain (#9) -- until then the admin has no other way in.
|
||||||
|
- "8090:8090"
|
||||||
healthcheck:
|
healthcheck:
|
||||||
# The image ships this endpoint; the web app waits for it to pass.
|
# The image ships this endpoint; the web app waits for it to pass.
|
||||||
test: ["CMD", "wget", "--spider", "-q", "http://localhost:8090/api/health"]
|
test: ["CMD", "wget", "--spider", "-q", "http://localhost:8090/api/health"]
|
||||||
@@ -44,3 +53,8 @@ services:
|
|||||||
TZ: Europe/Berlin
|
TZ: Europe/Berlin
|
||||||
# The web app reads content from PocketBase over the compose network.
|
# The web app reads content from PocketBase over the compose network.
|
||||||
PocketBase__BaseUrl: http://eb-pocketbase:8090
|
PocketBase__BaseUrl: http://eb-pocketbase:8090
|
||||||
|
ports:
|
||||||
|
# TEMPORARY: reach the site on the LAN (http://<unraid>:5000) while NPM is not
|
||||||
|
# in front yet. Replace with the external npm network once NPM terminates TLS
|
||||||
|
# and is the only path in (#9).
|
||||||
|
- "5000:8080"
|
||||||
Reference in new issue
Block a user