diff --git a/Elternbeirat.Web.Tests/PocketBaseClientTests.cs b/Elternbeirat.Web.Tests/PocketBaseClientTests.cs index b90033b..f5b1a87 100644 --- a/Elternbeirat.Web.Tests/PocketBaseClientTests.cs +++ b/Elternbeirat.Web.Tests/PocketBaseClientTests.cs @@ -7,7 +7,8 @@ namespace Elternbeirat.Web.Tests; /// with a known seed (see ). Because the data is fixed, /// the tests assert on exact values, and they need no network to the live instance. /// -public sealed class PocketBaseClientTests(PocketBaseFixture pocketBase) : IClassFixture +[Collection(PocketBaseTestGroup.Name)] +public sealed class PocketBaseClientTests(PocketBaseFixture pocketBase) { [Fact] public async Task Events_load_sorted_by_start() diff --git a/Elternbeirat.Web.Tests/PocketBaseFixture.cs b/Elternbeirat.Web.Tests/PocketBaseFixture.cs index 1c333ef..d6b3fe4 100644 --- a/Elternbeirat.Web.Tests/PocketBaseFixture.cs +++ b/Elternbeirat.Web.Tests/PocketBaseFixture.cs @@ -49,6 +49,14 @@ public sealed class PocketBaseFixture : IAsyncLifetime /// Creates a pointed at this container. public PocketBaseClient CreateClient() => new(_http); + /// + /// 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. + /// + public Uri BaseUrl => _http.BaseAddress + ?? throw new InvalidOperationException("PocketBase is not started yet."); + public async Task InitializeAsync() { // Clear any leftover from an earlier run that was aborted before DisposeAsync diff --git a/Elternbeirat.Web.Tests/PocketBaseTestGroup.cs b/Elternbeirat.Web.Tests/PocketBaseTestGroup.cs new file mode 100644 index 0000000..bccb367 --- /dev/null +++ b/Elternbeirat.Web.Tests/PocketBaseTestGroup.cs @@ -0,0 +1,14 @@ +namespace Elternbeirat.Web.Tests; + +/// +/// Groups every test that needs the seeded PocketBase container into one xUnit +/// collection, so the single (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 eb-test-stack; sharing avoids two classes fighting over it. +/// +[CollectionDefinition(Name)] +public sealed class PocketBaseTestGroup : ICollectionFixture +{ + public const string Name = "PocketBase"; +} diff --git a/Elternbeirat.Web.Tests/RouteSmokeTests.cs b/Elternbeirat.Web.Tests/RouteSmokeTests.cs index 919306d..f69f239 100644 --- a/Elternbeirat.Web.Tests/RouteSmokeTests.cs +++ b/Elternbeirat.Web.Tests/RouteSmokeTests.cs @@ -1,4 +1,5 @@ using System.Net; +using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Testing; namespace Elternbeirat.Web.Tests; @@ -7,19 +8,40 @@ namespace Elternbeirat.Web.Tests; /// Smoke tests: every known route must return 200, unknown routes must return /// 404. This catches broken content files, renamed slugs or routing regressions /// before a deploy. +/// +/// The post routes now read from PocketBase, so the tests point the web app at the +/// seeded container from (shared via the +/// collection) and assert against its known slugs. Requires Docker, like the +/// client tests. +/// /// -public sealed class RouteSmokeTests : IClassFixture> +[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 _baseFactory = new(); private readonly WebApplicationFactory _factory; - public RouteSmokeTests(WebApplicationFactory 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(); } /// /// 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. /// public static TheoryData KnownRoutes => [ @@ -35,11 +57,11 @@ public sealed class RouteSmokeTests : IClassFixtureStart Vorstandsteam Förderverein - Beiträge - Termine + Beiträge + Termine FAQ Downloads Kontakt diff --git a/Elternbeirat.Web/Elternbeirat.Web.csproj b/Elternbeirat.Web/Elternbeirat.Web.csproj index f05d606..70c00f9 100644 --- a/Elternbeirat.Web/Elternbeirat.Web.csproj +++ b/Elternbeirat.Web/Elternbeirat.Web.csproj @@ -22,6 +22,12 @@ + + + + + diff --git a/Elternbeirat.Web/Features/Events/Event.cs b/Elternbeirat.Web/Features/Events/Event.cs deleted file mode 100644 index fe94c93..0000000 --- a/Elternbeirat.Web/Features/Events/Event.cs +++ /dev/null @@ -1,33 +0,0 @@ -namespace Elternbeirat.Web.Features.Events; - -/// -/// A single calendar entry (parents' evening, meeting, deadline). Read from -/// Content/events.yml at startup. All display text is German because it -/// is visitor-facing content. -/// -public sealed class Event -{ - /// Short title shown in the list and the calendar. - public required string Title { get; init; } - - /// - /// When the entry starts. A date-only entry (no time) is treated as an - /// all-day event; see . - /// - public required DateTime Start { get; init; } - - /// - /// True when carries a wall-clock time, false for an - /// all-day entry. Drives both the list formatting and the ICS export. - /// - public required bool HasTime { get; init; } - - /// Optional end, for multi-hour or multi-day entries. - public DateTime? End { get; init; } - - /// Optional location, e.g. "Aula" or "Mensa". - public string? Location { get; init; } - - /// Optional free-text note shown below the entry. - public string? Note { get; init; } -} diff --git a/Elternbeirat.Web/Features/Events/EventList.razor b/Elternbeirat.Web/Features/Events/EventList.razor index 5d71ca1..def16d1 100644 --- a/Elternbeirat.Web/Features/Events/EventList.razor +++ b/Elternbeirat.Web/Features/Events/EventList.razor @@ -1,5 +1,4 @@ -@page "/termine" -@inject EventService EventService +@page "/events" Termine @@ -7,20 +6,20 @@

Alle Termine des Elternbeirats auf einen Blick. Sie können den Kalender auch - abonnieren und in Ihrer Kalender-App automatisch + abonnieren und in Ihrer Kalender-App automatisch aktuell halten.

Kommende Termine

-@if (EventService.Upcoming.Count == 0) +@if (_upcoming.Count == 0) {

Zurzeit sind keine Termine geplant.

} else {
    - @foreach (var ev in EventService.Upcoming) + @foreach (var ev in _upcoming) {
  • @@ -38,11 +37,11 @@ else
} -@if (EventService.Past.Count > 0) +@if (_past.Count > 0) {

Vergangene Termine

    - @foreach (var ev in EventService.Past) + @foreach (var ev in _past) {
  • diff --git a/Elternbeirat.Web/Features/Events/EventList.razor.cs b/Elternbeirat.Web/Features/Events/EventList.razor.cs index a17a7bd..e4d066c 100644 --- a/Elternbeirat.Web/Features/Events/EventList.razor.cs +++ b/Elternbeirat.Web/Features/Events/EventList.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.Events; @@ -7,11 +9,55 @@ 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); + + [Inject] + private PocketBaseClient PocketBase { get; set; } = default!; + + [Inject] + private ILogger Logger { get; set; } = default!; + + private IReadOnlyList _upcoming = []; + private IReadOnlyList _past = []; + + protected override async Task OnInitializedAsync() + { + IReadOnlyList 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 // time, or a span across days. 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", German); @@ -24,12 +70,12 @@ public partial class EventList // end date. if (end.Date == ev.Start.Date) { - return ev.HasTime + return HasTime(ev) ? start + "–" + end.ToString("HH:mm", German) + " Uhr" : start; } - var endText = ev.HasTime + var endText = HasTime(ev) ? end.ToString("d. MMMM yyyy, HH:mm", German) + " Uhr" : end.ToString("d. MMMM yyyy", German); return start + " – " + endText; diff --git a/Elternbeirat.Web/Features/Events/EventService.cs b/Elternbeirat.Web/Features/Events/EventService.cs deleted file mode 100644 index e813ce8..0000000 --- a/Elternbeirat.Web/Features/Events/EventService.cs +++ /dev/null @@ -1,138 +0,0 @@ -using System.Globalization; -using Elternbeirat.Web.Services; -using YamlDotNet.Serialization; -using YamlDotNet.Serialization.NamingConventions; - -namespace Elternbeirat.Web.Features.Events; - -/// -/// Reads the calendar entries from Content/events.yml 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 EventService -{ - private readonly IReadOnlyList _events; - - /// - /// Reads and parses Content/events.yml below the application root. - /// A missing file yields an empty list rather than an error. - /// - public EventService(IWebHostEnvironment environment) - { - var path = Path.Combine(environment.ContentRootPath, "Content", "events.yml"); - _events = File.Exists(path) - ? Parse(File.ReadAllText(path)) - : []; - } - - /// - /// 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. - /// - public IReadOnlyList Upcoming => - _events - .Where(e => DateOnly.FromDateTime(e.Start) >= DateOnly.FromDateTime(DateTime.Today)) - .OrderBy(e => e.Start) - .ToList(); - - /// - /// Entries that already passed, most recent first — the past section shown - /// below the upcoming ones. - /// - public IReadOnlyList Past => - _events - .Where(e => DateOnly.FromDateTime(e.Start) < DateOnly.FromDateTime(DateTime.Today)) - .OrderByDescending(e => e.Start) - .ToList(); - - /// All entries in chronological order, for the ICS export. - public IReadOnlyList All => - _events.OrderBy(e => e.Start).ToList(); - - private static List 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?>(yaml) ?? []; - return raw - .Where(entry => entry.Title is not null && entry.Start is not null) - .Select(ToEvent) - .OfType() - .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, - }; - } - - /// - /// Parses a value that is either a date (2026-03-15) or a date with a - /// time (2026-03-15 19:30). Sets so the - /// caller knows whether to treat the entry as all-day. - /// - 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; - } - - /// - /// 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. - /// - 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; } - } -} diff --git a/Elternbeirat.Web/Features/Events/IcsCalendar.cs b/Elternbeirat.Web/Features/Events/IcsCalendar.cs index e3d3b03..2d85388 100644 --- a/Elternbeirat.Web/Features/Events/IcsCalendar.cs +++ b/Elternbeirat.Web/Features/Events/IcsCalendar.cs @@ -1,7 +1,7 @@ using System.Globalization; using System.Security.Cryptography; using System.Text; -using Elternbeirat.Web.Services; +using Elternbeirat.Contracts; namespace Elternbeirat.Web.Features.Events; @@ -45,7 +45,7 @@ public static class IcsCalendar // between requests, so caches and clients do not see spurious changes. AppendLine(sb, "DTSTAMP:20000101T000000Z"); - if (ev.HasTime) + if (HasTime(ev)) { AppendLine(sb, $"DTSTART:{Local(ev.Start)}"); if (ev.End is { } end) @@ -75,6 +75,12 @@ public static class IcsCalendar AppendLine(sb, "END:VEVENT"); } + /// + /// 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. + /// + private static bool HasTime(Event ev) => ev.Start.TimeOfDay != TimeSpan.Zero; + /// /// 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. diff --git a/Elternbeirat.Web/Features/Home/Home.razor b/Elternbeirat.Web/Features/Home/Home.razor index b811c0e..48118f4 100644 --- a/Elternbeirat.Web/Features/Home/Home.razor +++ b/Elternbeirat.Web/Features/Home/Home.razor @@ -23,13 +23,13 @@ @foreach (var post in _latest) {
  • - @post.Title + @post.Title
  • }
-

Alle Beiträge →

+

Alle Beiträge →

} diff --git a/Elternbeirat.Web/Features/Posts/PostDetail.razor b/Elternbeirat.Web/Features/Posts/PostDetail.razor index ef31e63..718f4c2 100644 --- a/Elternbeirat.Web/Features/Posts/PostDetail.razor +++ b/Elternbeirat.Web/Features/Posts/PostDetail.razor @@ -1,5 +1,4 @@ -@page "/beitraege/{Slug}" -@inject PostService PostService +@page "/posts/{Slug}" @if (_post is null) { @@ -10,11 +9,11 @@ else @_post.Title } diff --git a/Elternbeirat.Web/Features/Posts/PostDetail.razor.cs b/Elternbeirat.Web/Features/Posts/PostDetail.razor.cs index e2d1e99..39e1df1 100644 --- a/Elternbeirat.Web/Features/Posts/PostDetail.razor.cs +++ b/Elternbeirat.Web/Features/Posts/PostDetail.razor.cs @@ -1,5 +1,7 @@ using System.Globalization; -using Elternbeirat.Web.Services; +using Elternbeirat.Contracts; +using Elternbeirat.PocketBase; +using Elternbeirat.Web.Shared; using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Http; @@ -9,23 +11,49 @@ public partial class PostDetail { 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] 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 Post? _post; - protected override void OnParametersSet() - { - _post = PostService.Find(Slug); + // The post body rendered from Markdown to HTML, ready to emit as a + // MarkupString in the markup. + private MarkupString Body => (MarkupString)Markdown.ToHtml(_post?.Body); - // Unknown slug -> 404, so UseStatusCodePagesWithReExecute serves the - // /not-found page instead of an empty 200 response. + protected override async Task OnParametersSetAsync() + { + 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) { HttpContext.Response.StatusCode = StatusCodes.Status404NotFound; } } - - [CascadingParameter] - private HttpContext? HttpContext { get; set; } } diff --git a/Elternbeirat.Web/Features/Posts/PostList.razor b/Elternbeirat.Web/Features/Posts/PostList.razor index 0301a89..c294cc4 100644 --- a/Elternbeirat.Web/Features/Posts/PostList.razor +++ b/Elternbeirat.Web/Features/Posts/PostList.razor @@ -1,21 +1,20 @@ -@page "/beitraege" -@inject PostService PostService +@page "/posts" Beiträge

Beiträge

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

Zurzeit gibt es keine Beiträge.

} else {
    - @foreach (var post in PostService.All) + @foreach (var post in _posts) {
  • - @post.Title + @post.Title diff --git a/Elternbeirat.Web/Features/Posts/PostList.razor.cs b/Elternbeirat.Web/Features/Posts/PostList.razor.cs index f582b7e..38d358b 100644 --- a/Elternbeirat.Web/Features/Posts/PostList.razor.cs +++ b/Elternbeirat.Web/Features/Posts/PostList.razor.cs @@ -1,8 +1,36 @@ using System.Globalization; +using Elternbeirat.Contracts; +using Elternbeirat.PocketBase; +using Microsoft.AspNetCore.Components; namespace Elternbeirat.Web.Features.Posts; 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"); + + [Inject] + private PocketBaseClient PocketBase { get; set; } = default!; + + [Inject] + private ILogger Logger { get; set; } = default!; + + private IReadOnlyList _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); + } + } } diff --git a/Elternbeirat.Web/Program.cs b/Elternbeirat.Web/Program.cs index ed34eb9..3ea5781 100644 --- a/Elternbeirat.Web/Program.cs +++ b/Elternbeirat.Web/Program.cs @@ -1,3 +1,5 @@ +using Elternbeirat.Contracts; +using Elternbeirat.PocketBase; using Elternbeirat.Web.Components; using Elternbeirat.Web.Features.Events; using Elternbeirat.Web.Services; @@ -8,10 +10,17 @@ var builder = WebApplication.CreateBuilder(args); // Add services to the container. 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(client => + client.BaseAddress = new Uri(pocketBaseUrl)); + // Content is read once at startup and cached -> singletons. builder.Services.AddSingleton(); builder.Services.AddSingleton(); -builder.Services.AddSingleton(); var app = builder.Build(); @@ -43,8 +52,21 @@ app.MapStaticAssets(); app.MapRazorComponents(); // Subscribable calendar feed of all events. A minimal API endpoint rather than -// a Razor page because it returns text/calendar, not HTML. -app.MapGet("/termine.ics", (EventService events) => - Results.Text(IcsCalendar.Build(events.All), "text/calendar; charset=utf-8")); +// a Razor page because it returns text/calendar, not HTML. Reads from PocketBase +// per request; an unreachable source yields an empty calendar, not a 500. +app.MapGet("/events.ics", async (PocketBaseClient pocketBase) => +{ + IReadOnlyList events; + try + { + events = await pocketBase.GetEventsAsync(); + } + catch (HttpRequestException) + { + events = []; + } + + return Results.Text(IcsCalendar.Build(events), "text/calendar; charset=utf-8"); +}); app.Run(); diff --git a/Elternbeirat.Web/Shared/Markdown.cs b/Elternbeirat.Web/Shared/Markdown.cs new file mode 100644 index 0000000..cc4309a --- /dev/null +++ b/Elternbeirat.Web/Shared/Markdown.cs @@ -0,0 +1,23 @@ +using Markdig; + +namespace Elternbeirat.Web.Shared; + +/// +/// Renders Markdown to HTML. Content now comes from PocketBase as raw Markdown +/// (the body field), so the pages render it at display time instead of +/// reading pre-rendered HTML from files. +/// +public static class Markdown +{ + private static readonly MarkdownPipeline Pipeline = + new MarkdownPipelineBuilder().Build(); + + /// + /// Converts a Markdown string to HTML. Returns an empty string for + /// null or empty input, so callers can bind the result directly. + /// + public static string ToHtml(string? markdown) => + string.IsNullOrEmpty(markdown) + ? "" + : Markdig.Markdown.ToHtml(markdown, Pipeline); +} diff --git a/Elternbeirat.Web/_Imports.razor b/Elternbeirat.Web/_Imports.razor index 57b29e7..ac14ee8 100644 --- a/Elternbeirat.Web/_Imports.razor +++ b/Elternbeirat.Web/_Imports.razor @@ -8,3 +8,4 @@ @using Microsoft.JSInterop @using Elternbeirat.Web @using Elternbeirat.Web.Services +@using Elternbeirat.Web.Shared diff --git a/Elternbeirat.Web/appsettings.Development.json b/Elternbeirat.Web/appsettings.Development.json index 0c208ae..b561eb0 100644 --- a/Elternbeirat.Web/appsettings.Development.json +++ b/Elternbeirat.Web/appsettings.Development.json @@ -4,5 +4,8 @@ "Default": "Information", "Microsoft.AspNetCore": "Warning" } + }, + "PocketBase": { + "BaseUrl": "http://localhost:8090" } } diff --git a/Elternbeirat.Web/appsettings.json b/Elternbeirat.Web/appsettings.json index 10f68b8..4ddd54e 100644 --- a/Elternbeirat.Web/appsettings.json +++ b/Elternbeirat.Web/appsettings.json @@ -5,5 +5,10 @@ "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" + } } diff --git a/compose.yaml b/compose.yaml index f996039..b207665 100644 --- a/compose.yaml +++ b/compose.yaml @@ -1,6 +1,10 @@ -# Base compose: production-like. Both services, no host port mappings (NPM is the -# only path to the web app; the editors reach PocketBase through an NPM subdomain). -# Only PocketBase's data is a volume; everything else lives inside the images. +# Base compose: production-like. Both services; 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 # the web image from source: @@ -25,6 +29,11 @@ services: volumes: # The only state that must survive a restart or redeploy. - /mnt/user/appdata/pocketbase/pb_data:/pb_data + ports: + # TEMPORARY: exposes the admin UI on the LAN (http://: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: # The image ships this endpoint; the web app waits for it to pass. test: ["CMD", "wget", "--spider", "-q", "http://localhost:8090/api/health"] @@ -44,3 +53,8 @@ services: TZ: Europe/Berlin # The web app reads content from PocketBase over the compose network. PocketBase__BaseUrl: http://eb-pocketbase:8090 + ports: + # TEMPORARY: reach the site on the LAN (http://: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"