Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5f28237ef0 | ||
|
|
818f4c508e | ||
|
|
ee43d133e9 | ||
|
|
cf799dd970 | ||
|
|
de319d3b00 | ||
|
|
43e6ed7743 | ||
|
|
4fa17429b5 | ||
|
|
666614a3b2 | ||
|
|
c67c71ea28 | ||
|
|
eacf95e041 | ||
|
|
cb345975cb | ||
|
|
e38ebf078d | ||
|
|
2ea8856d65 | ||
|
|
28944c155a |
No files matched your search
@@ -6,7 +6,9 @@ namespace Elternbeirat.Contracts;
|
||||
/// A single question and answer of the FAQ, as stored in PocketBase.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// On the FAQ page, entries are grouped by <see cref="Topic"/>.
|
||||
/// On the FAQ page, entries are grouped by their <see cref="Topic"/>. The topic is
|
||||
/// a PocketBase relation; <see cref="Topic"/> is the resolved topic record, present
|
||||
/// only when the query expanded the relation (see <see cref="FaqExpand"/>).
|
||||
/// </remarks>
|
||||
public record Faq
|
||||
{
|
||||
@@ -42,17 +44,66 @@ public record Faq
|
||||
public required string Answer { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the topic the question is grouped under, e.g.
|
||||
/// <c>"Mensa und Mittagessen"</c>.
|
||||
/// Gets the sort order of the question within its topic.
|
||||
/// </summary>
|
||||
/// <value>
|
||||
/// The sort key; questions with smaller values appear first.
|
||||
/// </value>
|
||||
[JsonPropertyName("order")]
|
||||
public double Order { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether this entry is published and may be shown to visitors.
|
||||
/// </summary>
|
||||
/// <value>
|
||||
/// <see langword="true"/> when the entry is public, <see langword="false"/> for a
|
||||
/// draft that must stay hidden.
|
||||
/// </value>
|
||||
/// <remarks>
|
||||
/// Collection queries filter on <c>public=true</c> server-side, so entries loaded
|
||||
/// that way are always public and never need this checked. It matters only for the
|
||||
/// <c>faqs_via_topic</c> back-relation expand behind <see cref="FaqTopic.Faqs"/>:
|
||||
/// PocketBase does not apply the topic query's <c>public</c> filter to expanded
|
||||
/// child records, so a draft question would otherwise leak onto the topic page.
|
||||
/// That getter filters on this to keep drafts hidden.
|
||||
/// </remarks>
|
||||
[JsonPropertyName("public")]
|
||||
public bool Public { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the expanded relations returned by PocketBase, or <see langword="null"/>
|
||||
/// when the query did not expand any.
|
||||
/// </summary>
|
||||
[JsonPropertyName("expand")]
|
||||
public FaqExpand? Expand { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the resolved topic this entry is grouped under, or <see langword="null"/>
|
||||
/// when the query did not expand the topic relation.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <see langword="required"/>: the FAQ page groups entries by topic, so an entry
|
||||
/// without one has no place. The field is required in PocketBase.
|
||||
///
|
||||
/// This is the heading shown on the page verbatim, not a slug: editors add a
|
||||
/// new group by typing its heading here, no code change. Entries with the same
|
||||
/// topic text land in one group.
|
||||
/// Convenience over <see cref="Expand"/>: the FAQ page reads the grouping
|
||||
/// heading from here. It is <see langword="null"/> unless the entry was loaded
|
||||
/// with the topic relation expanded.
|
||||
/// </remarks>
|
||||
[JsonIgnore]
|
||||
public FaqTopic? Topic => Expand?.Topic;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The relations of a <see cref="Faq"/> that PocketBase returns under
|
||||
/// <c>expand</c> when the query asks for them.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// One property per expandable relation, named after its PocketBase field, so the
|
||||
/// JSON <c>expand</c> object maps straight onto it. <see cref="Faq.Topic"/> reads
|
||||
/// through this.
|
||||
/// </remarks>
|
||||
public record FaqExpand
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the expanded topic of the <c>topic</c> relation, if it was expanded.
|
||||
/// </summary>
|
||||
[JsonPropertyName("topic")]
|
||||
public required string Topic { get; init; }
|
||||
public FaqTopic? Topic { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Elternbeirat.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// A topic the FAQ entries are grouped under, as stored in PocketBase.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A <see cref="Faq"/> points at its topic through a relation, so the FAQ page
|
||||
/// groups the questions by topic and shows the groups in <see cref="Order"/>.
|
||||
/// Making the topic its own record (rather than a free-text field on each
|
||||
/// question) means the heading, its order and its optional <see cref="Intro"/>
|
||||
/// are edited in one place and shared by every question in the group.
|
||||
/// </remarks>
|
||||
public record FaqTopic
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the PocketBase record id.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <see langword="required"/>: every stored record has an id. Modeling it as
|
||||
/// required states which fields a record must carry, independent of the store,
|
||||
/// so a future data source has to supply them too.
|
||||
/// </remarks>
|
||||
[JsonPropertyName("id")]
|
||||
public required string Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the heading shown above the group of questions, e.g.
|
||||
/// <c>"Mensa und Mittagessen"</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Free text; may contain umlauts and spaces. For the URL, see <see cref="Slug"/>.
|
||||
/// <see langword="required"/>: a topic without a heading has nothing to show, and
|
||||
/// the field is required in PocketBase.
|
||||
/// </remarks>
|
||||
[JsonPropertyName("title")]
|
||||
public required string Title { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the URL slug of the topic, e.g. <c>"cafeteria"</c>.
|
||||
/// </summary>
|
||||
/// <value>
|
||||
/// A URL path segment: lowercase letters, digits and single hyphens as
|
||||
/// separators, no umlauts. PocketBase enforces this on save via a field
|
||||
/// pattern (<c>^[a-z0-9]+(-[a-z0-9]+)*$</c>), so every stored slug is already
|
||||
/// canonical and the app can use it verbatim.
|
||||
/// </value>
|
||||
/// <remarks>
|
||||
/// <see langword="required"/>: the slug is required in PocketBase and is the
|
||||
/// stable identifier of a topic, independent of its display title.
|
||||
/// </remarks>
|
||||
[JsonPropertyName("slug")]
|
||||
public required string Slug { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the optional introduction shown below the topic heading, as Markdown.
|
||||
/// </summary>
|
||||
/// <value>
|
||||
/// The intro Markdown, or an empty string when the topic has none.
|
||||
/// </value>
|
||||
[JsonPropertyName("intro")]
|
||||
public string Intro { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the sort order of the topic among the FAQ groups.
|
||||
/// </summary>
|
||||
/// <value>
|
||||
/// The sort key; topics with smaller values appear first.
|
||||
/// </value>
|
||||
[JsonPropertyName("order")]
|
||||
public double Order { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the expanded relations returned by PocketBase, or <see langword="null"/>
|
||||
/// when the query did not expand any.
|
||||
/// </summary>
|
||||
[JsonPropertyName("expand")]
|
||||
public FaqTopicExpand? Expand { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the questions of this topic, ordered by <see cref="Faq.Order"/>, or an
|
||||
/// empty list when none are linked or the relation was not expanded.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Convenience over <see cref="Expand"/>: questions name their topic, so
|
||||
/// PocketBase returns them under the back-relation key when the query expands it
|
||||
/// (<c>faqs_via_topic</c>). The topic detail page reads them from here. They
|
||||
/// arrive unsorted, so this orders them by <see cref="Faq.Order"/> for a stable
|
||||
/// render. Drafts are dropped: PocketBase does not apply the topic query's
|
||||
/// <c>public</c> filter to the expanded children, so this keeps
|
||||
/// non-public questions (<see cref="Faq.Public"/>) off the page.
|
||||
/// </remarks>
|
||||
[JsonIgnore]
|
||||
public IReadOnlyList<Faq> Faqs =>
|
||||
Expand?.Faqs is { } faqs
|
||||
? [.. faqs.Where(faq => faq.Public).OrderBy(faq => faq.Order)]
|
||||
: [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The relations of a <see cref="FaqTopic"/> that PocketBase returns under
|
||||
/// <c>expand</c> when the query asks for them.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The questions point at their topic, so PocketBase exposes them as the
|
||||
/// back-relation <c>faqs_via_topic</c>. <see cref="FaqTopic.Faqs"/> reads through
|
||||
/// this.
|
||||
/// </remarks>
|
||||
public record FaqTopicExpand
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the expanded questions of the <c>faqs_via_topic</c> back-relation, if it
|
||||
/// was expanded.
|
||||
/// </summary>
|
||||
[JsonPropertyName("faqs_via_topic")]
|
||||
public IReadOnlyList<Faq>? Faqs { get; init; }
|
||||
}
|
||||
@@ -90,8 +90,8 @@ public record Page
|
||||
/// Gets the dynamic blocks rendered below the <see cref="Body"/>.
|
||||
/// </summary>
|
||||
/// <value>
|
||||
/// Any of <c>"posts"</c>, <c>"events"</c> and <c>"faqs"</c>, or an empty list
|
||||
/// for a plain text page.
|
||||
/// Any of <c>"posts"</c> and <c>"events"</c>, or an empty list for a plain
|
||||
/// text page.
|
||||
/// </value>
|
||||
[JsonPropertyName("embed")]
|
||||
public IReadOnlyList<string> Embed { get; init; } = [];
|
||||
|
||||
@@ -70,14 +70,17 @@ public sealed class PocketBaseClient(HttpClient httpClient)
|
||||
=> GetRecordsAsync<Event>("events", "start", token);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all public FAQ entries, ordered by <see cref="Faq.Topic"/>, then by
|
||||
/// <see cref="Faq.Question"/>.
|
||||
/// Gets all public FAQ entries, grouped by topic: ordered by the topic's own
|
||||
/// <c>order</c>, then by the entry's <c>order</c> within that topic. Each entry
|
||||
/// carries its expanded <see cref="Faq.Topic"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The second sort key makes the order total: sorting by topic alone leaves
|
||||
/// entries within a topic in an undefined order, so the same content could
|
||||
/// render differently between requests. Adding the question as a tie-breaker
|
||||
/// keeps the FAQ list and any teaser drawn from it stable.
|
||||
/// Sorting by <c>topic.order</c> first keeps all questions of a topic together
|
||||
/// and lists the topic groups in the order an editor gave the topics; the
|
||||
/// entry's own <c>order</c> then arranges the questions within each group. Both
|
||||
/// keys together make the order total, so the same content renders the same way
|
||||
/// between requests. The topic relation is expanded so the page can show each
|
||||
/// group's heading without a second request.
|
||||
/// </remarks>
|
||||
/// <inheritdoc cref="GetPagesAsync" path="/param"/>
|
||||
/// <returns>
|
||||
@@ -85,7 +88,62 @@ public sealed class PocketBaseClient(HttpClient httpClient)
|
||||
/// </returns>
|
||||
/// <inheritdoc cref="GetPagesAsync" path="/exception"/>
|
||||
public Task<IReadOnlyList<Faq>> GetFaqsAsync(CancellationToken token = default)
|
||||
=> GetRecordsAsync<Faq>("faqs", "topic,question", token);
|
||||
=> GetRecordsAsync<Faq>("faqs", "topic.order,order", token, expand: "topic");
|
||||
|
||||
/// <summary>
|
||||
/// Gets all public FAQ topics, ordered by <see cref="FaqTopic.Order"/>, each with
|
||||
/// its questions expanded.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Backs the FAQ page, which shows every topic as a collapsible group with its
|
||||
/// questions nested inside. The <c>faqs_via_topic</c> back-relation is expanded so
|
||||
/// one request returns the topics and their questions together; drafts among the
|
||||
/// questions are dropped in <see cref="FaqTopic.Faqs"/>.
|
||||
/// </remarks>
|
||||
/// <inheritdoc cref="GetPagesAsync" path="/param"/>
|
||||
/// <returns>
|
||||
/// The public FAQ topics with their questions, or an empty list if there are none.
|
||||
/// </returns>
|
||||
/// <inheritdoc cref="GetPagesAsync" path="/exception"/>
|
||||
public Task<IReadOnlyList<FaqTopic>> GetFaqTopicsAsync(CancellationToken token = default)
|
||||
=> GetRecordsAsync<FaqTopic>("faq_topics", "order", token, expand: "faqs_via_topic");
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether PocketBase answers its health endpoint.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Backs the app's own <c>/health</c> endpoint, which an external monitor (Uptime
|
||||
/// Kuma) polls: the app answering at all proves the app is up, and this probe adds
|
||||
/// whether the content source behind it is reachable, so one monitor covers both.
|
||||
/// This calls <c>GET /api/health</c>, PocketBase's own liveness endpoint, which
|
||||
/// needs no auth and touches no collection.
|
||||
/// </remarks>
|
||||
/// <param name="token">
|
||||
/// A token to cancel the probe.
|
||||
/// </param>
|
||||
/// <returns>
|
||||
/// <see langword="true"/> if PocketBase answered with a success status within the
|
||||
/// client timeout; <see langword="false"/> if it was unreachable, timed out, or
|
||||
/// answered with an error status.
|
||||
/// </returns>
|
||||
public async Task<bool> IsHealthyAsync(CancellationToken token = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var response = await httpClient.GetAsync(
|
||||
new Uri("/api/health", UriKind.Relative), token);
|
||||
return response.IsSuccessStatusCode;
|
||||
}
|
||||
// Same failure shapes as a record read: unreachable (HttpRequestException) or the
|
||||
// client's own timeout (a TaskCanceledException wrapping a TimeoutException). A
|
||||
// plain TaskCanceledException is the caller's cancellation and propagates.
|
||||
catch (Exception exception) when (
|
||||
exception is HttpRequestException
|
||||
or TaskCanceledException { InnerException: TimeoutException })
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all public records of a collection in a single request.
|
||||
@@ -102,6 +160,10 @@ public sealed class PocketBaseClient(HttpClient httpClient)
|
||||
/// <param name="token">
|
||||
/// A token to cancel the request.
|
||||
/// </param>
|
||||
/// <param name="expand">
|
||||
/// A PocketBase <c>expand</c> expression naming relations to resolve inline
|
||||
/// (comma-separated, dot-nested), or <see langword="null"/> to expand none.
|
||||
/// </param>
|
||||
/// <returns>
|
||||
/// The public records, or an empty list if there are none.
|
||||
/// </returns>
|
||||
@@ -114,7 +176,8 @@ public sealed class PocketBaseClient(HttpClient httpClient)
|
||||
private async Task<IReadOnlyList<T>> GetRecordsAsync<T>(
|
||||
string collection,
|
||||
string sort,
|
||||
CancellationToken token)
|
||||
CancellationToken token,
|
||||
string? expand = null)
|
||||
{
|
||||
// filter=public=true keeps drafts out; perPage is large enough to fetch
|
||||
// every record in a single page given the small content volume.
|
||||
@@ -122,6 +185,11 @@ public sealed class PocketBaseClient(HttpClient httpClient)
|
||||
+ $"?perPage=500&filter={Uri.EscapeDataString("public=true")}"
|
||||
+ $"&sort={Uri.EscapeDataString(sort)}";
|
||||
|
||||
// Add expand only when asked, so callers that need no relations send the
|
||||
// leaner request they did before.
|
||||
if (expand is not null)
|
||||
url += $"&expand={Uri.EscapeDataString(expand)}";
|
||||
|
||||
try
|
||||
{
|
||||
var result = await httpClient.GetFromJsonAsync<RecordList<T>>(url, token);
|
||||
|
||||
@@ -18,7 +18,7 @@ public sealed class PocketBaseClientTests(PocketBaseFixture pocketBase)
|
||||
var events = await client.GetEventsAsync();
|
||||
|
||||
// Two seeded events, earliest start first.
|
||||
events.Select(e => e.Title).ShouldBe(["Elternbeiratssitzung", "Herbstbasar"]);
|
||||
events.Select(@event => @event.Title).ShouldBe(["Elternbeiratssitzung", "Herbstbasar"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -46,8 +46,8 @@ public sealed class PocketBaseClientTests(PocketBaseFixture pocketBase)
|
||||
|
||||
var events = await client.GetEventsAsync();
|
||||
|
||||
var meeting = events.Single(e => e.Title == "Elternbeiratssitzung");
|
||||
var basar = events.Single(e => e.Title == "Herbstbasar");
|
||||
var meeting = events.Single(@event => @event.Title == "Elternbeiratssitzung");
|
||||
var basar = events.Single(@event => @event.Title == "Herbstbasar");
|
||||
meeting.End.ShouldBeNull(); // no end seeded
|
||||
basar.End.ShouldNotBeNull(); // end seeded
|
||||
}
|
||||
@@ -60,7 +60,7 @@ public sealed class PocketBaseClientTests(PocketBaseFixture pocketBase)
|
||||
var posts = await client.GetPostsAsync();
|
||||
|
||||
// Sorted by -date: March before January.
|
||||
posts.Select(p => p.Title).ShouldBe(["Neuer Vorstand", "Neue Sporthalle"]);
|
||||
posts.Select(post => post.Title).ShouldBe(["Neuer Vorstand", "Neue Sporthalle"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -85,9 +85,9 @@ public sealed class PocketBaseClientTests(PocketBaseFixture pocketBase)
|
||||
var pages = await client.GetPagesAsync();
|
||||
|
||||
// The draft (public=false) must be filtered out, the public ones present.
|
||||
pages.ShouldNotContain(p => p.Slug == "draft");
|
||||
pages.ShouldContain(p => p.Slug == "home");
|
||||
pages.ShouldContain(p => p.Slug == "contact");
|
||||
pages.ShouldNotContain(page => page.Slug == "draft");
|
||||
pages.ShouldContain(page => page.Slug == "home");
|
||||
pages.ShouldContain(page => page.Slug == "contact");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -111,7 +111,10 @@ public sealed class PocketBaseClientTests(PocketBaseFixture pocketBase)
|
||||
|
||||
var faqs = await client.GetFaqsAsync();
|
||||
|
||||
faqs.Select(f => f.Topic).ShouldBe(["Mensa und Mittagessen", "Schließfächer"], ignoreOrder: true);
|
||||
faqs.ShouldAllBe(f => !string.IsNullOrWhiteSpace(f.Question));
|
||||
// The topic relation is expanded, so each entry carries its topic. The two
|
||||
// seeded topics are ordered (Mensa order 1 before Schließfächer order 2), and
|
||||
// GetFaqsAsync sorts by topic.order, so the groups come back in that order.
|
||||
faqs.Select(faq => faq.Topic?.Title).ShouldBe(["Mensa und Mittagessen", "Schließfächer"]);
|
||||
faqs.ShouldAllBe(faq => !string.IsNullOrWhiteSpace(faq.Question));
|
||||
}
|
||||
}
|
||||
@@ -236,16 +236,18 @@ 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 = new[] { "faqs" }, @public = true,
|
||||
order = 3, slug = "patrons", embed = Array.Empty<string>(), @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.
|
||||
// Page record for the FAQ list. Route /faqs is served by FaqList, which reads
|
||||
// this record for its heading and intro (title + body) and renders the topic
|
||||
// cards itself. Slug matches production's "faqs" nav entry. The body carries
|
||||
// no "# heading": the title already renders as the h1.
|
||||
await CreateRecordAsync(http, "pages", new
|
||||
{
|
||||
title = "FAQ", body = "# Häufige Fragen", location = "header",
|
||||
title = "FAQs",
|
||||
body = "Wählen Sie ein Thema, um die passenden Fragen zu sehen.",
|
||||
location = "header",
|
||||
order = 4, slug = "faqs", embed = Array.Empty<string>(), @public = true,
|
||||
});
|
||||
await CreateRecordAsync(http, "pages", new
|
||||
@@ -253,6 +255,22 @@ public sealed class PocketBaseFixture : IAsyncLifetime
|
||||
title = "Downloads", body = "# Downloads", location = "header",
|
||||
order = 5, slug = "downloads", embed = Array.Empty<string>(), @public = true,
|
||||
});
|
||||
// Page records for the posts and events lists. Routes /posts and /events are
|
||||
// served by PostList and EventList, which read these records for their heading
|
||||
// and intro (title + body) and render the list themselves, just like FaqList.
|
||||
// The body carries no "# heading": the title already renders as the h1.
|
||||
await CreateRecordAsync(http, "pages", new
|
||||
{
|
||||
title = "Beiträge", body = "Neuigkeiten aus dem Elternbeirat.",
|
||||
location = "header",
|
||||
order = 6, slug = "posts", embed = Array.Empty<string>(), @public = true,
|
||||
});
|
||||
await CreateRecordAsync(http, "pages", new
|
||||
{
|
||||
title = "Termine", body = "Sitzungen und Veranstaltungen auf einen Blick.",
|
||||
location = "header",
|
||||
order = 7, slug = "events", embed = Array.Empty<string>(), @public = true,
|
||||
});
|
||||
// A plain content page reached only by its slug (not shown in the header),
|
||||
// so the catch-all "/{slug}" ContentPage route stays covered. It carries a
|
||||
// valid location because the field is required; footer keeps it low-key.
|
||||
@@ -313,23 +331,50 @@ public sealed class PocketBaseFixture : IAsyncLifetime
|
||||
title = "Herbstbasar", location = "Schulhof", note = "", @public = true,
|
||||
});
|
||||
|
||||
// Faqs: two topics.
|
||||
// Faq topics: two, ordered. The topics own the grouping and the order the FAQ
|
||||
// page shows the groups in (cafeteria before lockers). Titles stay German for
|
||||
// visitors; slugs are English, as for every route.
|
||||
var lunchTopicId = await CreateRecordAsync(http, "faq_topics", new
|
||||
{
|
||||
title = "Mensa und Mittagessen", slug = "cafeteria", intro = "",
|
||||
order = 1, @public = true,
|
||||
});
|
||||
var lockerTopicId = await CreateRecordAsync(http, "faq_topics", new
|
||||
{
|
||||
title = "Schließfächer", slug = "lockers", intro = "",
|
||||
order = 2, @public = true,
|
||||
});
|
||||
|
||||
// Faqs: each points at its topic through the "topic" relation (the created
|
||||
// topic id), and carries its own order within the topic.
|
||||
await CreateRecordAsync(http, "faqs", new
|
||||
{
|
||||
question = "Wann gibt es Mittagessen?", answer = "Um 12 Uhr.",
|
||||
topic = "Mensa und Mittagessen", @public = true,
|
||||
topic = lunchTopicId, order = 1, @public = true,
|
||||
});
|
||||
await CreateRecordAsync(http, "faqs", new
|
||||
{
|
||||
question = "Wie viel kostet ein Schließfach?", answer = "20 Euro.",
|
||||
topic = "Schließfächer", @public = true,
|
||||
topic = lockerTopicId, order = 1, @public = true,
|
||||
});
|
||||
}
|
||||
|
||||
private static async Task CreateRecordAsync(HttpClient http, string collection, object record)
|
||||
private static async Task<string> CreateRecordAsync(HttpClient http, string collection, object record)
|
||||
{
|
||||
var response = await http.PostAsJsonAsync($"/api/collections/{collection}/records", record);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
// Return the created record's id so a caller can seed a relation to it (e.g. a
|
||||
// faq pointing at its topic). Callers that do not need it just ignore the value.
|
||||
var created = await response.Content.ReadFromJsonAsync<CreatedRecord>();
|
||||
return created?.Id ?? throw new InvalidOperationException(
|
||||
$"PocketBase returned no id for the created '{collection}' record.");
|
||||
}
|
||||
|
||||
private sealed record CreatedRecord
|
||||
{
|
||||
[System.Text.Json.Serialization.JsonPropertyName("id")]
|
||||
public string Id { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
private sealed record AuthResponse
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using System.Net;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
|
||||
namespace Elternbeirat.Web.Tests;
|
||||
@@ -85,18 +84,19 @@ public sealed class RouteSmokeTests : IDisposable
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Faqs_route_renders_the_grouped_list()
|
||||
public async Task Faqs_route_renders_the_topic_accordion()
|
||||
{
|
||||
// /faqs is a literal route (FaqList) that must win over the /{Slug}
|
||||
// catch-all content page. Assert it shows the FAQ list, grouped by topic
|
||||
// from the seeded questions, not a content page body.
|
||||
// catch-all content page. It reads the "faqs" page for heading and intro
|
||||
// and renders each topic as a collapsible group with its questions nested
|
||||
// inside, so both the topic headings and the questions are in the markup.
|
||||
var client = _factory.CreateClient();
|
||||
|
||||
var html = await client.GetStringAsync(new Uri("/faqs", UriKind.Relative));
|
||||
|
||||
html.ShouldContain("Häufige Fragen");
|
||||
html.ShouldContain("Wann gibt es Mittagessen?");
|
||||
html.ShouldContain("Mensa und Mittagessen"); // German topic heading
|
||||
html.ShouldContain("FAQs"); // heading from the page title
|
||||
html.ShouldContain("Mensa und Mittagessen"); // German topic heading (a group)
|
||||
html.ShouldContain("Wann gibt es Mittagessen?"); // a question nested in the group
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -129,16 +129,62 @@ public sealed class RouteSmokeTests : IDisposable
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Content_page_renders_the_embed_its_page_opts_into()
|
||||
public async Task Content_page_renders_its_body()
|
||||
{
|
||||
// /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.
|
||||
// /patrons is a catch-all content page (the /{Slug} route). Assert it renders
|
||||
// its Markdown body, proving the ContentPage path works for an arbitrary slug.
|
||||
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
|
||||
html.ShouldContain("Förderverein"); // the page body, rendered from Markdown
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Posts_and_events_render_their_page_heading_and_body()
|
||||
{
|
||||
// /posts and /events are served by PostList and EventList, which -- like
|
||||
// FaqList -- read their own "posts"/"events" page record for heading and
|
||||
// intro instead of hard-coding them. Assert both the title (as the h1) and
|
||||
// the body text from the fixture records reach the markup.
|
||||
var client = _factory.CreateClient();
|
||||
|
||||
var posts = await client.GetStringAsync(new Uri("/posts", UriKind.Relative));
|
||||
posts.ShouldContain("Beiträge"); // heading from the page title
|
||||
posts.ShouldContain("Neuigkeiten aus dem Elternbeirat."); // the page body
|
||||
|
||||
var events = await client.GetStringAsync(new Uri("/events", UriKind.Relative));
|
||||
events.ShouldContain("Termine"); // heading from the page title
|
||||
events.ShouldContain("Sitzungen und Veranstaltungen auf einen Blick."); // the page body
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Health_returns_200_when_PocketBase_is_reachable()
|
||||
{
|
||||
// The monitor's happy path: the app answers and PocketBase (the seeded
|
||||
// fixture) is up, so /health is 200. This mostly guards the wiring -- that the
|
||||
// endpoint exists and reaches the client -- since the fixture keeps PocketBase
|
||||
// alive; the unreachable case is covered separately below.
|
||||
var client = _factory.CreateClient();
|
||||
|
||||
var response = await client.GetAsync(new Uri("/health", UriKind.Relative));
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Health_returns_503_when_PocketBase_is_unreachable()
|
||||
{
|
||||
// The case the monitor exists for: the app is up but PocketBase is not. Point a
|
||||
// throwaway app at a dead address (nothing listens on port 1, so the probe is
|
||||
// refused at once, no timeout wait) and assert /health reports 503 rather than
|
||||
// claiming healthy.
|
||||
await using var factory = _baseFactory.WithWebHostBuilder(builder =>
|
||||
builder.UseSetting("PocketBase:BaseUrl", "http://localhost:1"));
|
||||
var client = factory.CreateClient();
|
||||
|
||||
var response = await client.GetAsync(new Uri("/health", UriKind.Relative));
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.ServiceUnavailable);
|
||||
}
|
||||
}
|
||||
@@ -7,15 +7,16 @@
|
||||
@foreach (var ev in _upcomingEvents)
|
||||
{
|
||||
<li>
|
||||
<EventDate Value="ev" />
|
||||
<a class="card-link" href="/events">
|
||||
<span class="event-title">@ev.Title</span>
|
||||
<EventDate Value="ev" />
|
||||
@if (!string.IsNullOrWhiteSpace(ev.Location))
|
||||
{
|
||||
<span class="event-location">@ev.Location</span>
|
||||
}
|
||||
</a>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
<p class="embed-more"><a href="/events">Alle Termine <span aria-hidden="true">→</span></a></p>
|
||||
</section>
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
@if (_faqs.Count > 0)
|
||||
{
|
||||
<section class="embed embed-faqs">
|
||||
<h2>Häufige Fragen</h2>
|
||||
@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 class="embed-more"><a href="/faqs">Alle Fragen <span aria-hidden="true">→</span></a></p>
|
||||
</section>
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
using Elternbeirat.Contracts;
|
||||
using Elternbeirat.PocketBase;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
|
||||
namespace Elternbeirat.Web.Features.Embeds;
|
||||
|
||||
public partial class FaqsEmbed
|
||||
{
|
||||
// Number of questions shown in the teaser; the full hub is one link away.
|
||||
private const int TeaserCount = 3;
|
||||
|
||||
[LoggerMessage(
|
||||
Level = LogLevel.Warning,
|
||||
Message = "Could not load FAQs for an embed from PocketBase; the teaser is omitted.")]
|
||||
private static partial void LogSourceUnavailable(
|
||||
ILogger logger,
|
||||
Exception exception);
|
||||
|
||||
[Inject]
|
||||
private PocketBaseClient PocketBase { get; set; } = null!;
|
||||
|
||||
[Inject]
|
||||
private ILogger<FaqsEmbed> Logger { get; set; } = null!;
|
||||
|
||||
[CascadingParameter]
|
||||
private HttpContext? HttpContext { get; set; }
|
||||
|
||||
private IReadOnlyList<Faq> _faqs = [];
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
// GetFaqsAsync is ordered by topic, then question, so the first entries
|
||||
// the teaser takes are stable across requests.
|
||||
var faqs = await PocketBase.GetFaqsAsync(HttpContext?.RequestAborted ?? CancellationToken.None);
|
||||
_faqs = [.. faqs.Take(TeaserCount)];
|
||||
}
|
||||
catch (PocketBaseUnavailableException exception)
|
||||
{
|
||||
// Only part of the page fails: omit the teaser, keep the page (and its
|
||||
// 200) intact. A missing teaser is degraded, not broken.
|
||||
LogSourceUnavailable(Logger, exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
/* Card styles for .faq-item are shared with the full FAQ page and live in the
|
||||
global app.css. Only the embed's spacing on the home page is specific here. */
|
||||
.embed-faqs {
|
||||
margin-top: var(--space-5);
|
||||
}
|
||||
@@ -12,14 +12,11 @@
|
||||
case "events":
|
||||
<EventsEmbed />
|
||||
break;
|
||||
case "faqs":
|
||||
<FaqsEmbed />
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@code {
|
||||
/// <summary>The page's embed keys ("posts", "events", "faqs"), in order.</summary>
|
||||
/// <summary>The page's embed keys ("posts", "events"), in order.</summary>
|
||||
[Parameter]
|
||||
public IReadOnlyList<string> Embed { get; set; } = [];
|
||||
}
|
||||
@@ -7,11 +7,12 @@
|
||||
@foreach (var post in _latest)
|
||||
{
|
||||
<li>
|
||||
<a href="@($"/posts/{post.Slug}")">@post.Title</a>
|
||||
<a class="card-link" href="@($"/posts/{post.Slug}")">
|
||||
<span class="post-title">@post.Title</span>
|
||||
<PostDate Value="post.Date" />
|
||||
</a>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
<p class="embed-more"><a href="/posts">Alle Beiträge <span aria-hidden="true">→</span></a></p>
|
||||
</section>
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
/* The date's own look: small and softened, matching <PostDate> so post and event
|
||||
cards read as one style. Lives with the component so every place that renders
|
||||
<EventDate> (list, home embed) shows the date the same way. Positioning (margin
|
||||
to neighbours) is NOT set here -- that is the surrounding list's job. */
|
||||
time {
|
||||
display: block;
|
||||
color: var(--color-text-soft); /* token, so it adapts in dark mode */
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
@@ -1,8 +1,19 @@
|
||||
@page "/events"
|
||||
|
||||
<PageTitle>Termine</PageTitle>
|
||||
@{
|
||||
// Heading and intro come from the "events" page record; fall back to a sensible
|
||||
// default only if that record is missing, so the page still renders.
|
||||
var title = _page?.Title ?? "Termine";
|
||||
}
|
||||
|
||||
<h1>Termine</h1>
|
||||
<PageTitle>@title</PageTitle>
|
||||
|
||||
<h1>@title</h1>
|
||||
|
||||
@if (_page is not null && !string.IsNullOrWhiteSpace(_page.Body))
|
||||
{
|
||||
<article>@Body</article>
|
||||
}
|
||||
|
||||
@if (_unavailable)
|
||||
{
|
||||
@@ -28,8 +39,9 @@ else
|
||||
@foreach (var ev in _upcomingEvents)
|
||||
{
|
||||
<li>
|
||||
<EventDate Value="ev" />
|
||||
<a class="card-link" href="/events">
|
||||
<span class="event-title">@ev.Title</span>
|
||||
<EventDate Value="ev" />
|
||||
@if (!string.IsNullOrWhiteSpace(ev.Location))
|
||||
{
|
||||
<span class="event-location">@ev.Location</span>
|
||||
@@ -38,6 +50,7 @@ else
|
||||
{
|
||||
<p class="event-note">@ev.Note</p>
|
||||
}
|
||||
</a>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
@@ -50,12 +63,14 @@ else
|
||||
@foreach (var ev in _pastEvents)
|
||||
{
|
||||
<li>
|
||||
<EventDate Value="ev" />
|
||||
<a class="card-link" href="/events">
|
||||
<span class="event-title">@ev.Title</span>
|
||||
<EventDate Value="ev" />
|
||||
@if (!string.IsNullOrWhiteSpace(ev.Location))
|
||||
{
|
||||
<span class="event-location">@ev.Location</span>
|
||||
}
|
||||
</a>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
|
||||
@@ -23,6 +23,11 @@ public partial class EventList
|
||||
[CascadingParameter]
|
||||
private HttpContext? HttpContext { get; set; }
|
||||
|
||||
// The "events" page record supplies the heading and intro text, so an editor
|
||||
// owns them in PocketBase like every other page, instead of them being
|
||||
// hard-coded here. Null until loaded, or when no such page exists.
|
||||
private Page? _page;
|
||||
|
||||
private IReadOnlyList<Event> _upcomingEvents = [];
|
||||
private IReadOnlyList<Event> _pastEvents = [];
|
||||
|
||||
@@ -31,12 +36,19 @@ public partial class EventList
|
||||
// 503 this sets on the response.
|
||||
private bool _unavailable;
|
||||
|
||||
// The page body rendered from Markdown to HTML, ready to emit as a MarkupString.
|
||||
private MarkupString Body => (MarkupString)Markdown.ToHtml(_page?.Body);
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
IReadOnlyList<Event> events;
|
||||
try
|
||||
{
|
||||
events = await PocketBase.GetEventsAsync(HttpContext?.RequestAborted ?? CancellationToken.None);
|
||||
var token = HttpContext?.RequestAborted ?? CancellationToken.None;
|
||||
var pages = await PocketBase.GetPagesAsync(token);
|
||||
_page = pages.FirstOrDefault(
|
||||
page => string.Equals(page.Slug, "events", StringComparison.OrdinalIgnoreCase));
|
||||
events = await PocketBase.GetEventsAsync(token);
|
||||
}
|
||||
catch (PocketBaseUnavailableException exception)
|
||||
{
|
||||
|
||||
@@ -1,24 +1,39 @@
|
||||
@page "/faqs"
|
||||
|
||||
<PageTitle>Häufige Fragen</PageTitle>
|
||||
@{
|
||||
// Heading and intro come from the "faqs" page record; fall back to a sensible
|
||||
// default only if that record is missing, so the page still renders.
|
||||
var title = _page?.Title ?? "FAQs";
|
||||
}
|
||||
|
||||
<h1>Häufige Fragen</h1>
|
||||
<PageTitle>@title</PageTitle>
|
||||
|
||||
<h1>@title</h1>
|
||||
|
||||
@if (_page is not null && !string.IsNullOrWhiteSpace(_page.Body))
|
||||
{
|
||||
<article>@Body</article>
|
||||
}
|
||||
|
||||
@if (_unavailable)
|
||||
{
|
||||
<p>Die Fragen sind gerade nicht erreichbar. Bitte später erneut versuchen.</p>
|
||||
}
|
||||
else if (_groups.Count == 0)
|
||||
else if (_topics.Count == 0)
|
||||
{
|
||||
<p>Zurzeit gibt es keine Fragen.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
@foreach (var group in _groups)
|
||||
@foreach (var topic in _topics)
|
||||
{
|
||||
<section class="faq-topic">
|
||||
<h2>@group.Key</h2>
|
||||
@foreach (var faq in group)
|
||||
<details class="faq-topic">
|
||||
<summary>@topic.Title</summary>
|
||||
@if (!string.IsNullOrWhiteSpace(topic.Intro))
|
||||
{
|
||||
<div class="faq-topic-intro">@((MarkupString)Markdown.ToHtml(topic.Intro))</div>
|
||||
}
|
||||
@foreach (var faq in topic.Faqs)
|
||||
{
|
||||
<details class="faq-item">
|
||||
<summary>@faq.Question</summary>
|
||||
@@ -27,6 +42,6 @@ else
|
||||
</div>
|
||||
</details>
|
||||
}
|
||||
</section>
|
||||
</details>
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,7 @@ public partial class FaqList
|
||||
{
|
||||
[LoggerMessage(
|
||||
Level = LogLevel.Error,
|
||||
Message = "Could not load FAQs from PocketBase; content source is unavailable.")]
|
||||
Message = "Could not load FAQ topics from PocketBase; content source is unavailable.")]
|
||||
private static partial void LogSourceUnavailable(
|
||||
ILogger logger,
|
||||
Exception exception);
|
||||
@@ -23,23 +23,35 @@ public partial class FaqList
|
||||
[CascadingParameter]
|
||||
private HttpContext? HttpContext { get; set; }
|
||||
|
||||
private IReadOnlyList<IGrouping<string, Faq>> _groups = [];
|
||||
// The "faqs" page record supplies the heading and intro text, so an editor
|
||||
// owns them in PocketBase rather than them being hard-coded here. Null until
|
||||
// loaded, or when no such page exists.
|
||||
private Page? _page;
|
||||
|
||||
private IReadOnlyList<FaqTopic> _topics = [];
|
||||
|
||||
// Set when the content source could not be reached or read. The markup then
|
||||
// shows a "temporarily unavailable" note instead of "no questions", matching
|
||||
// shows a "temporarily unavailable" note instead of "no topics", matching
|
||||
// the 503 this sets on the response.
|
||||
private bool _unavailable;
|
||||
|
||||
// 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);
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var faqs = await PocketBase.GetFaqsAsync(HttpContext?.RequestAborted ?? CancellationToken.None);
|
||||
_groups = [.. faqs.GroupBy(faq => faq.Topic)];
|
||||
var token = HttpContext?.RequestAborted ?? CancellationToken.None;
|
||||
var pages = await PocketBase.GetPagesAsync(token);
|
||||
_page = pages.FirstOrDefault(
|
||||
page => string.Equals(page.Slug, "faqs", StringComparison.OrdinalIgnoreCase));
|
||||
_topics = await PocketBase.GetFaqTopicsAsync(token);
|
||||
}
|
||||
catch (PocketBaseUnavailableException exception)
|
||||
{
|
||||
// An empty page would claim there are no questions. The site works, its
|
||||
// An empty page would claim there are no topics. The site works, its
|
||||
// data store is down: 503 and an honest note.
|
||||
LogSourceUnavailable(Logger, exception);
|
||||
_unavailable = true;
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
/* The .faq-item card styles are shared with the home-page embed and live in the
|
||||
global app.css. Only the topic grouping is specific to this full page (the
|
||||
embed shows a flat list without topic headings). */
|
||||
.faq-topic {
|
||||
margin-bottom: var(--space-5);
|
||||
}
|
||||
|
||||
.faq-topic h2 {
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
@@ -1,8 +1,19 @@
|
||||
@page "/posts"
|
||||
|
||||
<PageTitle>Beiträge</PageTitle>
|
||||
@{
|
||||
// Heading and intro come from the "posts" page record; fall back to a sensible
|
||||
// default only if that record is missing, so the page still renders.
|
||||
var title = _page?.Title ?? "Beiträge";
|
||||
}
|
||||
|
||||
<h1>Beiträge</h1>
|
||||
<PageTitle>@title</PageTitle>
|
||||
|
||||
<h1>@title</h1>
|
||||
|
||||
@if (_page is not null && !string.IsNullOrWhiteSpace(_page.Body))
|
||||
{
|
||||
<article>@Body</article>
|
||||
}
|
||||
|
||||
@if (_unavailable)
|
||||
{
|
||||
@@ -18,8 +29,10 @@ else
|
||||
@foreach (var post in _posts)
|
||||
{
|
||||
<li>
|
||||
<a href="@($"/posts/{post.Slug}")">@post.Title</a>
|
||||
<a class="card-link" href="@($"/posts/{post.Slug}")">
|
||||
<span class="post-title">@post.Title</span>
|
||||
<PostDate Value="post.Date" />
|
||||
</a>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
|
||||
@@ -23,6 +23,11 @@ public partial class PostList
|
||||
[CascadingParameter]
|
||||
private HttpContext? HttpContext { get; set; }
|
||||
|
||||
// The "posts" page record supplies the heading and intro text, so an editor owns
|
||||
// them in PocketBase like every other page, instead of them being hard-coded here.
|
||||
// Null until loaded, or when no such page exists.
|
||||
private Page? _page;
|
||||
|
||||
private IReadOnlyList<Post> _posts = [];
|
||||
|
||||
// Set when the content source could not be reached or read. The markup then
|
||||
@@ -30,11 +35,18 @@ public partial class PostList
|
||||
// 503 this sets on the response.
|
||||
private bool _unavailable;
|
||||
|
||||
// The page body rendered from Markdown to HTML, ready to emit as a MarkupString.
|
||||
private MarkupString Body => (MarkupString)Markdown.ToHtml(_page?.Body);
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
_posts = await PocketBase.GetPostsAsync(HttpContext?.RequestAborted ?? CancellationToken.None);
|
||||
var token = HttpContext?.RequestAborted ?? CancellationToken.None;
|
||||
var pages = await PocketBase.GetPagesAsync(token);
|
||||
_page = pages.FirstOrDefault(
|
||||
page => string.Equals(page.Slug, "posts", StringComparison.OrdinalIgnoreCase));
|
||||
_posts = await PocketBase.GetPostsAsync(token);
|
||||
}
|
||||
catch (PocketBaseUnavailableException exception)
|
||||
{
|
||||
|
||||
+31
-21
@@ -6,52 +6,50 @@ using Microsoft.AspNetCore.HttpOverrides;
|
||||
|
||||
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).
|
||||
// comes from config: 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));
|
||||
{
|
||||
client.BaseAddress = new Uri(pocketBaseUrl);
|
||||
// Every page waits for PocketBase. The default wait is 100 seconds, so if
|
||||
// PocketBase is down the page would hang that long. 5 seconds fails fast instead.
|
||||
client.Timeout = TimeSpan.FromSeconds(5);
|
||||
});
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
// NPM terminates TLS and is the only way to reach the container (no port
|
||||
// mapping in production). Without UseForwardedHeaders the app sees every request
|
||||
// as HTTP and with the proxy IP instead of the client IP.
|
||||
// KnownNetworks/KnownProxies are deliberately empty because only NPM reaches
|
||||
// the container.
|
||||
// NPM sits in front of the app and handles HTTPS. Without this line the app would
|
||||
// think every request is plain HTTP and would see NPM's IP, not the visitor's.
|
||||
// KnownNetworks/KnownProxies are left empty on purpose: only NPM reaches the app.
|
||||
app.UseForwardedHeaders(
|
||||
new ForwardedHeadersOptions
|
||||
{
|
||||
ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto
|
||||
});
|
||||
|
||||
// Configure the HTTP request pipeline.
|
||||
if (app.Environment.IsDevelopment() is false)
|
||||
{
|
||||
app.UseExceptionHandler("/Error", createScopeForErrors: true);
|
||||
// No UseHsts() and no UseHttpsRedirection(): NPM sets HSTS and terminates
|
||||
// TLS. Both here would create a redirect loop behind the proxy.
|
||||
// No UseHsts() and no UseHttpsRedirection() here: NPM already handles HTTPS.
|
||||
// Adding them behind NPM would send the browser into a redirect loop.
|
||||
}
|
||||
|
||||
// No UseStatusCodePagesWithReExecute: the Router renders the NotFoundPage
|
||||
// (Pages.NotFound) in process for both an unmatched route and an explicit
|
||||
// NavigationManager.NotFound() from a component (see Routes.razor). Re-executing
|
||||
// every error code to /not-found would also turn a 503 (content source down) into
|
||||
// a "not found" page; leaving it out lets those components keep their own
|
||||
// "temporarily unavailable" markup and 503 status.
|
||||
// Don't add UseStatusCodePagesWithReExecute here. Blazor already shows its own
|
||||
// not-found page. That middleware would catch *every* error code, so when PocketBase
|
||||
// is down (a 503) it would wrongly show "not found" instead of our own message.
|
||||
app.UseAntiforgery();
|
||||
|
||||
app.MapStaticAssets();
|
||||
app.MapRazorComponents<App>();
|
||||
|
||||
// Subscribable calendar feed of all events. A minimal API endpoint rather than
|
||||
// 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.
|
||||
// Calendar feed people can subscribe to. It returns calendar data, not a web page,
|
||||
// so it is a plain endpoint, not a Razor page. If PocketBase is down it returns an
|
||||
// empty calendar rather than an error.
|
||||
app.MapGet("/events.ics", async (PocketBaseClient pocketBase, CancellationToken token) =>
|
||||
{
|
||||
IReadOnlyList<Event> events;
|
||||
@@ -67,4 +65,16 @@ app.MapGet("/events.ics", async (PocketBaseClient pocketBase, CancellationToken
|
||||
return Results.Text(IcsCalendar.Build(events), "text/calendar; charset=utf-8");
|
||||
});
|
||||
|
||||
// Status page for an outside monitor (Uptime Kuma) to check. One check tells all
|
||||
// three states apart: no reply = the app is down, 503 = the app runs but PocketBase
|
||||
// is down, 200 = both are fine. It talks to PocketBase directly, not through the
|
||||
// page-rendering code, so it can still report when that code is the thing failing.
|
||||
app.MapGet("/health", async (PocketBaseClient pocketBase, CancellationToken token) =>
|
||||
await pocketBase.IsHealthyAsync(token)
|
||||
? Results.Text("healthy", "text/plain; charset=utf-8")
|
||||
: Results.Text(
|
||||
"PocketBase unreachable",
|
||||
"text/plain; charset=utf-8",
|
||||
statusCode: StatusCodes.Status503ServiceUnavailable));
|
||||
|
||||
app.Run();
|
||||
@@ -219,51 +219,98 @@ h1:focus {
|
||||
background: var(--color-bg);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
transition: box-shadow var(--transition), transform var(--transition);
|
||||
transition: box-shadow var(--transition);
|
||||
}
|
||||
|
||||
.post-list li:hover,
|
||||
.event-list li:hover {
|
||||
box-shadow: var(--shadow-hover);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
/* Posts */
|
||||
.post-list a {
|
||||
font-size: 1.15rem;
|
||||
font-weight: 600;
|
||||
/* The whole card is one link. The <a> fills the card and carries the padding (so
|
||||
the entire surface is clickable, not just the text), and resets link colour and
|
||||
underline so the content inside -- title, date, location -- keeps its own look. */
|
||||
.card-link {
|
||||
display: block;
|
||||
padding: var(--space-3) var(--space-4);
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* The date's look (small, softened) comes from PostDate.razor.css. Here only the
|
||||
list-specific spacing: nudge the date down from the post's title link above. */
|
||||
.post-list time {
|
||||
margin-top: var(--space-1);
|
||||
/* FAQ topics: each is a collapsible card. Its summary is the topic heading; open it
|
||||
to reveal the optional intro and the questions (themselves collapsible .faq-item
|
||||
cards). The topic marker is a rotating triangle, distinct from the +/- on the
|
||||
questions nested inside, so the two accordion levels read apart. */
|
||||
.faq-topic {
|
||||
background: var(--color-bg);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
margin-bottom: var(--space-3);
|
||||
transition: box-shadow var(--transition);
|
||||
}
|
||||
|
||||
/* Events: the date leads in the warm accent colour. */
|
||||
.event-list time {
|
||||
display: block;
|
||||
color: var(--color-accent-dark);
|
||||
.faq-topic:hover {
|
||||
box-shadow: var(--shadow-hover);
|
||||
}
|
||||
|
||||
.faq-topic > summary {
|
||||
cursor: pointer;
|
||||
font-size: 1.15rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-brand);
|
||||
list-style: none;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.faq-topic > summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.faq-topic > summary::before {
|
||||
content: "▸";
|
||||
color: var(--color-accent-dark);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.faq-topic[open] > summary::before {
|
||||
content: "▾";
|
||||
}
|
||||
|
||||
/* Space the questions off the topic heading once the topic is open. */
|
||||
.faq-topic[open] > .faq-item:first-of-type {
|
||||
margin-top: var(--space-3);
|
||||
}
|
||||
|
||||
.faq-topic-intro {
|
||||
margin-top: var(--space-2);
|
||||
color: var(--color-text-soft);
|
||||
}
|
||||
|
||||
/* Post and event cards are built the same way, so they read as one style on the
|
||||
home page where both appear: the title leads in the brand colour (the whole card
|
||||
is the link, so the title itself is plain text), the date sits quietly beneath. */
|
||||
.post-title,
|
||||
.event-title {
|
||||
display: block;
|
||||
margin-top: var(--space-1);
|
||||
font-size: 1.15rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-brand);
|
||||
}
|
||||
|
||||
.event-location::before {
|
||||
content: " · ";
|
||||
color: var(--color-text-soft);
|
||||
/* The date's small, softened look comes from PostDate.razor.css / EventDate; here
|
||||
only the shared spacing: nudge it down from the title above. */
|
||||
.post-list time,
|
||||
.event-list time {
|
||||
margin-top: var(--space-1);
|
||||
}
|
||||
|
||||
/* The location sits on its own quiet line beneath the date. */
|
||||
.event-location {
|
||||
display: block;
|
||||
margin-top: var(--space-1);
|
||||
color: var(--color-text-soft);
|
||||
}
|
||||
|
||||
@@ -280,7 +327,6 @@ h1:focus {
|
||||
}
|
||||
|
||||
.event-list-past li:hover {
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
@@ -338,33 +384,3 @@ h1:focus {
|
||||
padding-top: var(--space-2);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
/* "Alle Beiträge/Termine/Fragen →" links below an embedded list on the home page.
|
||||
Set apart from the cards with extra space above, and styled as a distinct call
|
||||
to action in the accent colour rather than looking like body text. The arrow
|
||||
nudges right on hover for a subtle bit of life. */
|
||||
.embed-more {
|
||||
margin-top: var(--space-4);
|
||||
max-width: none; /* override the global paragraph reading cap */
|
||||
}
|
||||
|
||||
.embed-more a {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
font-weight: 600;
|
||||
color: var(--color-accent-dark);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.embed-more a:hover {
|
||||
color: var(--color-brand);
|
||||
}
|
||||
|
||||
.embed-more a span {
|
||||
transition: transform var(--transition);
|
||||
}
|
||||
|
||||
.embed-more a:hover span {
|
||||
transform: translateX(3px);
|
||||
}
|
||||
+12
-6
@@ -3,8 +3,12 @@
|
||||
#
|
||||
# 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.
|
||||
# PocketBase through an NPM subdomain (#9). NPM still needs a target address and
|
||||
# port, so a port stays reachable either way -- the plan is to reach the containers
|
||||
# by name over a shared Docker network (e.g. eb-blazor:8080) instead of publishing
|
||||
# them on the LAN, and to drop these host mappings that expose the site and admin UI
|
||||
# to anyone on the LAN. While NPM is not set up yet, these mappings 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:
|
||||
@@ -31,8 +35,9 @@ services:
|
||||
- /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.
|
||||
# NPM is not in front yet. Replace once the editors reach PocketBase through an
|
||||
# NPM subdomain (#9): NPM points at eb-pocketbase:8090 over a shared network, so
|
||||
# this host mapping can go -- 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.
|
||||
@@ -55,6 +60,7 @@ services:
|
||||
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).
|
||||
# in front yet. Replace once NPM terminates TLS and fronts the site (#9): join
|
||||
# the external NPM network and let NPM point at eb-blazor:8080 by name, so this
|
||||
# host mapping can go and the site is no longer open on the LAN.
|
||||
- "5000:8080"
|
||||
@@ -21,17 +21,25 @@ migrate((app) => {
|
||||
const schema = JSON.parse(toString($os.readFile("/pb_migrations/collections_schema.json")))
|
||||
|
||||
const records = {
|
||||
pages: [{"body":"# Vorstandsteam\n\nDer Elternbeirat der IGMH vertritt die Interessen aller Eltern gegenüber der Schulleitung und dem Kollegium. Wir treffen uns regelmäßig, bringen Anliegen aus der Elternschaft ein und begleiten das Schulleben aktiv.\n\n## Vorstand\n\n- **Vorsitz** — Anke Berger\n- **Stellvertretung** — Murat Yılmaz\n- **Kasse** — Sabine Krause\n- **Schriftführung** — Thomas Heinrich\n\n## So erreichen Sie uns\n\nAm schnellsten per Mail über unsere [Kontaktseite](/contact). Zu unseren öffentlichen Sitzungen sind alle Eltern herzlich eingeladen — die Termine finden Sie unter [Termine](/events).","embed":[],"location":"header","order":2,"public":true,"slug":"board","title":"Vorstandsteam"},{"body":"# Förderverein\n\nDer Förderverein der IGMH unterstützt Projekte, für die im regulären Schulhaushalt kein Geld vorgesehen ist: von der Ausstattung der Bibliothek über Pausenspielgeräte bis zu Zuschüssen für Klassenfahrten, damit kein Kind aus finanziellen Gründen zurückbleibt.\n\n## Mitglied werden\n\nDer Jahresbeitrag beträgt 24 € — jeder Euro fließt direkt in die Arbeit an der Schule. Den Aufnahmeantrag finden Sie unter [Downloads](/downloads).\n\n## Spenden\n\nSpenden sind jederzeit willkommen und werden auf Wunsch mit einer Zuwendungsbestätigung quittiert. Sprechen Sie uns über die [Kontaktseite](/contact) an.","embed":[],"location":"header","order":3,"public":true,"slug":"patrons","title":"Förderverein"},{"body":"# Häufige Fragen\n\nHier beantworten wir die Fragen, die uns am häufigsten erreichen.\n\nIhre Frage ist nicht dabei? Schreiben Sie uns über die [Kontaktseite](/contact).","embed":[],"location":"header","order":6,"public":true,"slug":"faqs","title":"FAQ"},{"body":"# Downloads\n\nFormulare und Dokumente zum Herunterladen:\n\n- Aufnahmeantrag Förderverein\n- Einzugsermächtigung\n- Erstattungsformular Klassenkasse\n\nFehlt ein Dokument? Melden Sie sich über die [Kontaktseite](/contact).","embed":[],"location":"header","order":7,"public":true,"slug":"downloads","title":"Downloads"},{"body":"# Kontakt\n\nSie erreichen den Elternbeirat der IGMH per E-Mail:\n\n**elternbeirat@igmh.example**\n\nWir antworten in der Regel innerhalb weniger Tage. Bei dringenden schulischen Angelegenheiten wenden Sie sich bitte direkt an das Sekretariat der Schule.","embed":[],"location":"footer","order":1,"public":true,"slug":"contact","title":"Kontakt"},{"body":"# Impressum\n\nAngaben gemäß § 5 DDG\n\n**Elternbeirat der IGMH**\nVertreten durch den Vorstand\n\nKontakt: siehe [Kontaktseite](/contact)\n\nVerantwortlich für den Inhalt nach § 18 Abs. 2 MStV: der Vorstand des Elternbeirats.","embed":[],"location":"footer","order":2,"public":true,"slug":"imprint","title":"Impressum"},{"body":"# Datenschutz\n\nDer Schutz Ihrer Daten ist uns wichtig. Diese Website kommt ohne externe Dienste aus: keine Analysewerkzeuge, keine eingebundenen Karten oder Videos, keine Schriften von fremden Servern.\n\n## Server-Logs\n\nBeim Aufruf werden technisch notwendige Zugriffsdaten (IP-Adresse, Zeitpunkt, aufgerufene Seite) kurzzeitig verarbeitet, um den Betrieb sicherzustellen. Eine Zusammenführung mit anderen Daten findet nicht statt.\n\n## Ihre Rechte\n\nFragen zum Datenschutz richten Sie bitte an die [Kontaktseite](/contact).","embed":[],"location":"footer","order":3,"public":true,"slug":"privacy","title":"Datenschutz"},{"body":"# Willkommen beim Elternbeirat der IGMH","embed":["posts","events"],"location":"header","order":1,"public":true,"slug":"home","title":"Start"},{"body":"# Beiträge","embed":[],"location":"header","order":4,"public":true,"slug":"posts","title":"Beiträge"},{"body":"# Termine","embed":[],"location":"header","order":5,"public":true,"slug":"events","title":"Termine"}]
|
||||
pages: [{"body":"Der Elternbeirat der IGMH vertritt die Interessen aller Eltern gegenüber der Schulleitung und dem Kollegium. Wir treffen uns regelmäßig, bringen Anliegen aus der Elternschaft ein und begleiten das Schulleben aktiv.\n\nDer Vorstand wurde am 13. November 2025 gewählt.\n\n## Vorsitz\n\n- **Maik Palm** (Vorsitzender) \n *Schulkonferenz, Mensarat, Homepage* \n Mitglied im Gesamtelternbeirat und im Schulbeirat der Stadt Mannheim.\n- **Gentiana Erol** (1. Stellvertreterin) \n *Schulkonferenz, Kommunikation Hausmeister, Mensarat*\n- **Raul Chira** (2. Stellvertreter) \n *Mensarat*\n\n## Schriftführung\n\n- **Marion Leininger** \n *Kasse, Schriftführung, Kontakt Förderverein*\n\n## Beisitz\n\n- **Jenny Reger-Stilgenbauer** \n *Schulkonferenz, Schaukasten*\n- **Mariella Ianello** \n *Mensarat, Kontakt Förderverein*\n- **Isabell Vesper**\n- **Susanne Naumburg** \n *Stellvertretende Schulkonferenz*\n- **Rebecca Goldmann**\n- **Michael Weber** \n *Stellvertretende Schulkonferenz*\n\n## So erreichen Sie uns\n\nAm schnellsten per Mail über unsere [Kontaktseite](/contact). Zu unseren öffentlichen Sitzungen sind alle Eltern herzlich eingeladen. Die Termine finden Sie unter [Termine](/events).","embed":[],"location":"header","order":2,"public":true,"slug":"board","title":"Vorstandsteam"},{"body":"Der Förderverein **„Freunde der IGMH e.V.\"** unterstützt die Schule seit 1975 bei Anschaffungen und Projekten, die aus dem regulären Schulbudget nicht finanziert werden können. Mehr als 500 Mitglieder aus Eltern, Lehrkräften, Ehemaligen und Förderern bringen sich mit Zeit, Ideen oder finanziellen Beiträgen ein.\n\nDer Förderverein ist ein eigenständiger, gemeinnützig anerkannter Verein und nicht Teil des Elternbeirats.\n\n## Was der Verein fördert\n\n- die pädagogische Arbeit und bessere Rahmenbedingungen an der Schule\n- musische, sportliche und kulturelle Projekte\n- Anschaffungen, die allen Schülerinnen und Schülern zugutekommen\n- Chancengerechtigkeit, damit kein Kind aus finanziellen Gründen zurückbleibt\n\n## Was bereits gefördert wurde\n\n- Rennräder, Bälle, Trikots sowie Sport- und Spielgeräte für AGs\n- Zuschüsse für Chorwochenenden und Kanutage\n- Unterstützung der Theater-AG\n- Veranstaltungstechnik und Musikinstrumente, zum Beispiel DJ-Mischpult und Klavier\n- Vor- und Mitfinanzierung der Schülerhandbücher\n- Kletterwand, Außenmöbel in den Innenhöfen, Sitzbänke im Schulhaus und Wasserspender im Mensabereich\n- Laptops und Tablets für den Unterricht\n\n## Mitglied werden\n\nMitglied werden kann, wer die Arbeit der Schule unterstützen möchte. Eine Mitgliedschaft ist bereits ab einem Jahresbeitrag von **10 €** möglich.\n\nDie Beitrittserklärung füllen Sie online beim Förderverein aus:\n[Zur Beitrittserklärung](https://easyverein.com/public/FDI/applicationform/21612)\n\n## Spenden\n\nSpenden sind jederzeit willkommen und werden auf Wunsch mit einer Zuwendungsbestätigung quittiert. Der Verein ist als gemeinnützig anerkannt. Wie Sie spenden können, erfahren Sie beim Förderverein.\n\n## Kontakt zum Förderverein\n\n**Freunde der IGMH e.V.** \nHerzogenriedstraße 50 \n68169 Mannheim \n(Fach 165)\n\nE-Mail: [foerderverein@igmh.de](mailto:foerderverein@igmh.de) \nWebsite: [foerderverein.igmh.de](https://foerderverein.igmh.de)","embed":[],"location":"header","order":3,"public":true,"slug":"patrons","title":"Förderverein"},{"body":"Auf dieser Seite haben wir die Fragen zusammengetragen, die uns am häufigsten erreichen.\n\nIhre Frage ist nicht dabei? Schreiben Sie uns über die [Kontaktseite](/contact) – wir helfen gern weiter.","embed":[],"location":"header","order":6,"public":true,"slug":"faqs","title":"FAQs"},{"body":"# Downloads\n\nFormulare und Dokumente zum Herunterladen:\n\n- Aufnahmeantrag Förderverein\n- Einzugsermächtigung\n- Erstattungsformular Klassenkasse\n\nFehlt ein Dokument? Melden Sie sich über die [Kontaktseite](/contact).","embed":[],"location":"header","order":7,"public":true,"slug":"downloads","title":"Downloads"},{"body":"Haben Sie eine Frage, eine Anregung oder möchten Sie sich einbringen? Der Elternbeirat der IGMH freut sich über jede Nachricht.\n\nSie erreichen uns per E-Mail unter [kontakt@elternbeirat-igmh.info](mailto:kontakt@elternbeirat-igmh.info). In der Regel melden wir uns innerhalb weniger Tage zurück.\n\nBei dringenden schulischen Angelegenheiten wenden Sie sich bitte direkt an das Sekretariat der IGMH.","embed":[],"location":"footer","order":1,"public":true,"slug":"contact","title":"Kontakt"},{"body":"# Impressum\n\nAngaben gemäß § 5 DDG\n\n**Elternbeirat der IGMH**\nVertreten durch den Vorstand\n\nKontakt: siehe [Kontaktseite](/contact)\n\nVerantwortlich für den Inhalt nach § 18 Abs. 2 MStV: der Vorstand des Elternbeirats.","embed":[],"location":"footer","order":2,"public":true,"slug":"imprint","title":"Impressum"},{"body":"# Datenschutz\n\nDer Schutz Ihrer Daten ist uns wichtig. DieLine truncated
|
||||
,
|
||||
posts: [{"body":"## Neuer Vorstand gewählt\n\nAuf der Mitgliederversammlung wurde der Vorstand des Elternbeirats für das Schuljahr **2026/27** neu gewählt. Wir danken dem bisherigen Team für sein Engagement und freuen uns auf die gemeinsame Arbeit.\n\nDie neuen Ansprechpartnerinnen und Ansprechpartner finden Sie auf der Seite [Vorstandsteam](/board).","date":"2026-09-24 18:00:00.000Z","public":true,"slug":"new-board-2026","title":"Neuer Vorstand für das Schuljahr 2026/27 gewählt"},{"body":"## Herbstbasar am 22. November\n\nAm **22. November** findet wieder unser beliebter Herbstbasar statt. Es gibt selbst gebackene Kuchen, Bastelstände und ein Bühnenprogramm der Klassen.\n\nWer einen Stand betreuen oder etwas beisteuern möchte, meldet sich bitte bis zum 10. November über das [Kontaktformular](/contact) an. Wir freuen uns über jede helfende Hand!","date":"2026-09-18 09:30:00.000Z","public":true,"slug":"herbstbasar-2026","title":"Herbstbasar am 22. November – jetzt anmelden"},{"body":"## Mehr Auswahl beim Mittagessen\n\nAb den Herbstferien erweitert der Caterer das Angebot in der Mensa: Es gibt künftig **täglich ein zusätzliches vegetarisches Gericht** sowie eine größere Salatauswahl.\n\nRückmeldungen zum Essen nehmen wir gerne über den Elternbeirat auf – sprechen Sie uns an.","date":"2026-09-10 12:15:00.000Z","public":true,"slug":"mensa-mehr-auswahl","title":"Mehr Auswahl beim Mittagessen ab den Herbstferien"},{"body":"## Besuchen Sie unseren Stand\n\nAm kommenden **Elternsprechtag** ist der Elternbeirat mit einem Info-Stand im Foyer vertreten. Kommen Sie vorbei – wir beantworten Fragen rund um die Elternarbeit und freuen uns über neue Mitstreiterinnen und Mitstreiter.\n\nAlle Termine finden Sie unter [Termine](/events).","date":"2026-09-02 08:00:00.000Z","public":true,"slug":"elternsprechtag-stand","title":"Elternbeirat am Elternsprechtag – besuchen Sie unseren Stand"},{"body":"## Neue Spielgeräte für den Pausenhof\n\nDank der Einnahmen aus dem letzten Basar und der Unterstützung des [Fördervereins](/patrons) konnten neue Spielgeräte für den Pausenhof angeschafft werden.\n\nDie Kinder haben die Klettergerüste bereits begeistert in Beschlag genommen.","date":"2026-08-28 16:45:00.000Z","public":true,"slug":"neue-spielgeraete","title":"Neue Spielgeräte für den Pausenhof"}]
|
||||
posts: [{"body":"In fast allen Familien wird über die Handy-Nutzung diskutiert. Auch im schulischen Alltag ist das immer wieder ein Thema.\n\nSinnvolles und weniger Sinnvolles ist mit dem Handy möglich. Viele Jugendliche haben das Handy bis in die späten Abendstunden und über Nacht bei sich, was natürlich zur Nutzung verführt. Innerhalb der Familie dazu Regelungen zu finden, ist nicht einfach. Auch wir in der Schule kennen diese Gespräche.\n\nWir möchten Ihnen ein Angebot digitaler Elternmodule zu diesem Themenfeld vorstellen und kurz aus schulischer, sozialpädagogischer und strafrechtlicher Sicht zur Handynutzung informieren.\n\n**Dienstag, 18. Februar 2025 um 18 Uhr im Hörsaal 2.**\n\nBitte geben Sie kurz über die Klassenleitung Rückmeldung, dass Sie teilnehmen werden.","date":"2025-02-07 12:00:00.000Z","public":true,"slug":"elternabend-handy-nutzung","title":"Elternabend zur Handy-Nutzung"},{"body":"Am 8. Februar öffnete die IGMH ihre Türen für interessierte Eltern und Kinder, die sich einen Eindruck von unserer Schule verschaffen wollten. Besonders Eltern, die planen, ihre Kinder im kommenden Schuljahr anzumelden, konnten sich umfassend informieren und direkt einen Anmeldetermin vereinbaren.\n\n## Vielfältige Einblicke in das Schulleben\n\nAn verschiedenen Stationen lernten die Besucherinnen und Besucher das breite Angebot der IGMH kennen. Die Eliteschule des Sports präsentierte ihre Fördermöglichkeiten, und Schulhund Tonka ließ viele Kinderherzen höherschlagen. Auch der Elternbeirat und der Förderverein der IGMH informierten in der Aula über ihre Arbeit.\n\nFür die jungen Gäste gab es zahlreiche Mitmach-Aktionen:\n\n- Im römischen Zimmer konnten sie sich spielerisch mit der lateinischen Sprache beschäftigen.\n- Im Chemielabor wurden spannende Experimente durchgeführt.\n- Die Kletterwand war ein echter Publikumsmagnet, hier ging es unter professioneller Sicherung hoch hinaus.\n\n## Unterhaltung und kulinarische Highlights\n\n- Die Schülerband begeisterte das Publikum mit ihren Auftritten in der Aula.\n- Im Hörsaal 2 führten die 6. und 7. Klassen eine unterhaltsame Theateraufführung auf.\n- Für das leibliche Wohl war bestens gesorgt, ob im gemütlichen Schülercafé oder im Bistro Français.\n\nDer Tag der offenen Tür war ein voller Erfolg und lockte zahlreiche Besucherinnen und Besucher an. Die positive Resonanz zeigt, wie groß das Interesse an unserer Schule ist.","date":"2025-02-09 12:00:00.000Z","public":true,"slug":"tag-der-offenen-tuer-2025","title":"Rückblick: Tag der offenen Tür an der IGMH"},{"body":"Digitale Medien spielen eine zentrale Rolle im Alltag von Kindern und Jugendlichen, sei es das Smartphone, ein Tablet oder die Spielkonsole. Doch was machen junge Menschen eigentlich damit? Welcher Erwachsene weiß genau, was sich auf WhatsApp, Instagram oder Snapchat abspielt? Und was passiert auf TikTok? Wann wird ein Videospiel zur Sucht? Und welches Vorbild geben wir als Erwachsene ab, wenn auch unser eigener Alltag vom Smartphone bestimmt wird?\n\n## Digitaler Medienelternabend an der IGMH\n\nVor Kurzem fand an der IGMH ein digitaler Medienelternabend statt, der auf großes Interesse stieß. Frau Ruthig, stellvertretende Mittelstufenleiterin, führte in das Thema ein und beleuchtete die Herausforderungen der Mediennutzung.\n\nAnschließend erklärte Schulpolizist Herr Ebert die juristischen Aspekte. Dabei zeigte er auf, welche Möglichkeiten Eltern haben, wenn ihr Kind von Cybermobbing betroffen ist:\n\n- Gespräch mit Schule und Polizeibeamten\n- Gespräch mit dem Präventionsbeamten der Polizei\n- Durchführung eines Präventionstermins in der Klasse\n- Mit Einverständnis der Eltern des Täters: Gespräch mit dem Täter\n- Mit Einverständnis der Eltern des Täters: Gespräch mit Täter, Opfer, Eltern und Schule\n- Strafanzeige durch die Polizei\n\nWeiterführende Informationen zu Computerspielen finden Sie unter [usk.de](http://www.usk.de) (USK Unterhaltungssoftware) und [zavatar.de](http://www.zavatar.de).\n\n## Herausforderungen der Handynutzung aus sozialpädagogischer Sicht\n\nFrau Graske (Sozialpädagogin) stellte die Herausforderungen aus Sicht der Schulsozialarbeit dar und gab Eltern konkrete Tipps:\n\n**1. Bildschirmzeit im Blick behalten**\n\n**2. Handy am Abend außer Reichweite bringen.** Vereinbaren Sie einen festen Schlafplatz für das Handy und aktivieren Sie einen Ruhemodus.\n\n**3. Online-Kontakte des Kindes sensibel begleiten.** Lassen Sie sich Chat-Gruppen zeigen, schauen Sie gemeinsam das TikTok-Profil an und nutzen Sie Ratschläge von [Clemens hilft](https://www.clemenshilft.de/).\n\n**4. Bleiben Sie die wichtigste Ansprechperson für Ihr Kind.** Zeigen Sie, dass Ihr Kind Ihnen vertrauen kann, begründen Sie Ihre Maßnahmen und bleiben Sie offen für Fragen. Der [Mediennutzungsvertrag](https://www.mediennutzungsvertrag.de/) hilft bei klaren Absprachen.\n\n**5. Denken Sie auch an sich selbst.** Anerkennen Sie Ihren Einsatz, seien
Line truncated
|
||||
,
|
||||
events: [{"end":"","location":"Aula","note":"Gäste sind willkommen. Um Anmeldung im Sekretariat wird gebeten.","public":true,"start":"2026-10-08 19:30:00.000Z","title":"Elternbeiratssitzung"},{"end":"2026-10-20 18:30:00.000Z","location":"Klassenräume","note":"Terminvergabe über das Schulportal.","public":true,"start":"2026-10-20 16:00:00.000Z","title":"Elternsprechtag"},{"end":"2026-11-22 13:00:00.000Z","location":"Schulhof","note":"Kuchenspenden willkommen.","public":true,"start":"2026-11-22 09:00:00.000Z","title":"Herbstbasar"},{"end":"2027-01-06 00:00:00.000Z","location":"","note":"","public":true,"start":"2026-12-19 00:00:00.000Z","title":"Weihnachtsferien"},{"end":"","location":"Mensa","note":"Kennenlernen bei Butterbrezeln und Kaffee.","public":true,"start":"2026-09-10 18:00:00.000Z","title":"Willkommensabend der neuen Fünftklässler"}]
|
||||
events: [{"end":"","location":"","note":"Die neue Internetseite des Elternbeirats der IGMH löst die bisherige ab und ist ab diesem Tag unter der neuen Adresse erreichbar.","public":true,"start":"2026-10-14 22:00:00.000Z","title":"Neue Website des Elternbeirats geht online"},{"end":"","location":"IGMH","note":"","public":true,"start":"2025-02-07 23:00:00.000Z","title":"Tag der offenen Tür der IGMH"},{"end":"","location":"IGMH","note":"","public":true,"start":"2025-02-24 23:00:00.000Z","title":"Wahl des Elternbeiratsvorstands"},{"end":"","location":"Hörsaal 2 der IGMH","note":"","public":true,"start":"2025-11-13 18:00:00.000Z","title":"Vollversammlung des Elternbeirats mit Vorstandswahl"},{"end":"","location":"IGMH","note":"","public":true,"start":"2025-12-08 23:00:00.000Z","title":"1. Sitzung des Elternbeiratsvorstands"},{"end":"","location":"IGMH","note":"","public":true,"start":"2025-12-16 23:00:00.000Z","title":"Adventstüren-Contest"},{"end":"","location":"IGMH","note":"","public":true,"start":"2026-03-09 23:00:00.000Z","title":"2. Sitzung des Elternbeiratsvorstands"}]
|
||||
,
|
||||
faqs: [{"answer":"Die Anmeldung läuft über das Bestellsystem der Schule. Die Zugangsdaten erhalten Sie zu Schuljahresbeginn über die Klassenleitung.","public":true,"question":"Wie melde ich mein Kind zum Mittagessen an?","topic":"Mensa und Mittagessen"},{"answer":"Ein Mittagessen kostet aktuell **4,20 €**. Für Familien mit Anspruch auf Leistungen nach dem Bildungs- und Teilhabepaket ist das Essen kostenfrei – sprechen Sie das Sekretariat an.","public":true,"question":"Was kostet ein Mittagessen?","topic":"Mensa und Mittagessen"},{"answer":"Ja, täglich steht mindestens ein vegetarisches Gericht zur Wahl.","public":true,"question":"Gibt es vegetarische Gerichte?","topic":"Mensa und Mittagessen"},{"answer":"Schließfächer werden über einen externen Anbieter gemietet. Das Anmeldeformular liegt bei den [Downloads](/downloads).","public":true,"question":"Wie bekommt mein Kind ein Schließfach?","topic":"Schließfächer"},{"answer":"Die Miete beträgt rund 15 € pro Schuljahr zuzüglich einer Kaution, die bei Rückgabe erstattet wird.","public":true,"question":"Was kostet die Miete?","topic":"Schließfächer"},{"answer":"Wenden Sie sich an den Anbieter; die Kontaktdaten stehen auf dem Mietvertrag. Bei Fragen hilft auch der [Elternbeirat](/contact).","public":true,"question":"Der Schlüssel ist verloren – was nun?","topic":"Schließfächer"},{"answer":"Der Eltern-Euro ist ein **freiwilliger** Beitrag, mit dem kleinere Anschaffungen für die Klassen unkompliziert finanziert werden – etwa Bastelmaterial oder Ausflüge.","public":true,"question":"Was ist der Eltern-Euro?","topic":"Eltern-Euro"},{"answer":"Nein. Der Beitrag ist ausdrücklich freiwillig, und niemand wird bei Nichtzahlung benachteiligt.","public":true,"question":"Ist der Beitrag verpflichtend?","topic":"Eltern-Euro"},{"answer":"Ob Schulfest, Basar oder eine helfende Hand bei Ausflügen – über jede Unterstützung freuen wir uns. Melden Sie sich einfach über die [Kontaktseite](/contact).","public":true,"question":"Wie kann ich mich engagieren?","topic":"Elternarbeit und Mitmachen"},{"answer":"Nein. Viele Aufgaben erledigen Eltern, die einfach mithelfen möchten, ganz ohne Amt.","public":true,"question":"Muss ich in den Elternbeirat gewählt sein, um zu helfen?","topic":"Elternarbeit und Mitmachen"}]
|
||||
// Topics own the grouping and the order of the FAQ page; a question links to
|
||||
// its topic below via the "topic" relation, resolved by slug at seed time.
|
||||
faq_topics: [{"title":"Elternarbeit","slug":"parent-involvement","intro":"","order":1,"public":true},{"title":"Eltern-Euro","slug":"parent-euro","intro":"","order":2,"public":true},{"title":"Mensa und Mittagessen","slug":"cafeteria","intro":"","order":3,"public":true},{"title":"Schließfächer","slug":"lockers","intro":"","order":4,"public":true}]
|
||||
,
|
||||
// "topic" is a slug here, not a relation id: the record loader below resolves it
|
||||
// to the faq_topics id, since the ids are only known once the topics are saved.
|
||||
faqs: [{"answer":"Die Anmeldung läuft über das Bestellsystem der Schule. Die Zugangsdaten erhalten Sie zu Schuljahresbeginn über die Klassenleitung.","public":true,"question":"Wie melde ich mein Kind zum Mittagessen an?","topic":"cafeteria","order":1},{"answer":"Ein Mittagessen kostet aktuell **4,20 €**. Für Familien mit Anspruch auf Leistungen nach dem Bildungs- und Teilhabepaket ist das Essen kostenfrei – sprechen Sie das Sekretariat an.","public":true,"question":"Was kostet ein Mittagessen?","topic":"cafeteria","order":2},{"answer":"Ja, täglich steht mindestens ein vegetarisches Gericht zur Wahl.","public":true,"question":"Gibt es vegetarische Gerichte?","topic":"cafeteria","order":3},{"answer":"Schließfächer werden über einen externen Anbieter gemietet. Das Anmeldeformular liegt bei den [Downloads](/downloads).","public":true,"question":"Wie bekommt mein Kind ein Schließfach?","topic":"lockers","order":1},{"answer":"Die Miete beträgt rund 15 € pro Schuljahr zuzüglich einer Kaution, die bei Rückgabe erstattet wird.","public":true,"question":"Was kostet die Miete?","topic":"lockers","order":2},{"answer":"Wenden Sie sich an den Anbieter; die Kontaktdaten stehen auf dem Mietvertrag. Bei Fragen hilft auch der [Elternbeirat](/contact).","public":true,"question":"Der Schlüssel ist verloren – was nun?","topic":"lockers","order":3},{"answer":"Der Eltern-Euro ist ein **freiwilliger** Beitrag, mit dem kleinere Anschaffungen für die Klassen unkompliziert finanziert werden – etwa Bastelmaterial oder Ausflüge.","public":true,"question":"Was ist der Eltern-Euro?","topic":"parent-euro","order":1},{"answer":"Nein. Der Beitrag ist ausdrücklich freiwillig, und niemand wird bei Nichtzahlung benachteiligt.","public":true,"question":"Ist der Beitrag verpflichtend?","topic":"parent-euro","order":2},{"answer":"Ob Schulfest, Basar oder eine helfende Hand bei Ausflügen – über jede Unterstützung freuen wir uns. Melden Sie sich einfach über die [Kontaktseite](/contact).","public":true,"question":"Wie kann ich mich engagieren?","topic":"parent-involvement","order":1},{"answer":"Nein. Viele Aufgaben erledigen Eltern, die einfach mithelfen möchten, ganz ohne Amt.","public":true,"question":"Muss ich in den Elternbeirat gewählt sein, um zu helfen?","topic":"parent-involvement","order":2}]
|
||||
,
|
||||
}
|
||||
|
||||
// Phase 1: schema. Create any collection that is not present yet.
|
||||
// Phase 1: schema. Create any collection that is not present yet. The schema
|
||||
// file lists faq_topics before faqs, so the target of the faqs "topic" relation
|
||||
// exists by the time faqs is created.
|
||||
for (const name of Object.keys(schema)) {
|
||||
try {
|
||||
app.findCollectionByNameOrId(name)
|
||||
@@ -42,13 +50,20 @@ migrate((app) => {
|
||||
|
||||
// Phase 2: records. Fill a collection only while it is still empty, so a
|
||||
// populated volume (or, should this ever run there, production) is untouched.
|
||||
// faqs are seeded after faq_topics (object key order), so the slug->id map is
|
||||
// populated before a faq resolves its "topic" against it.
|
||||
const faqTopicIdBySlug = {}
|
||||
for (const [name, rows] of Object.entries(records)) {
|
||||
if (app.countRecords(name) > 0) continue
|
||||
const collection = app.findCollectionByNameOrId(name)
|
||||
for (const data of rows) {
|
||||
// A faq's "topic" is a topic slug in this seed; swap it for the real id.
|
||||
if (name === "faqs") data = { ...data, topic: faqTopicIdBySlug[data.topic] }
|
||||
const record = new Record(collection)
|
||||
record.load(data)
|
||||
app.save(record)
|
||||
// Remember each topic's id so the faqs seeded afterwards can point at it.
|
||||
if (name === "faq_topics") faqTopicIdBySlug[data.slug] = record.id
|
||||
}
|
||||
}
|
||||
}, (app) => {
|
||||
|
||||
@@ -309,7 +309,7 @@
|
||||
"help": "",
|
||||
"hidden": false,
|
||||
"id": "select1203373331",
|
||||
"maxSelect": 3,
|
||||
"maxSelect": 2,
|
||||
"name": "embed",
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
@@ -317,8 +317,7 @@
|
||||
"type": "select",
|
||||
"values": [
|
||||
"posts",
|
||||
"events",
|
||||
"faqs"
|
||||
"events"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -337,6 +336,107 @@
|
||||
"updated": "2026-09-23 11:25:35.648Z",
|
||||
"system": false
|
||||
},
|
||||
"faq_topics": {
|
||||
"id": "pbc_529028860",
|
||||
"listRule": "",
|
||||
"viewRule": "",
|
||||
"createRule": null,
|
||||
"updateRule": null,
|
||||
"deleteRule": null,
|
||||
"name": "faq_topics",
|
||||
"type": "base",
|
||||
"fields": [
|
||||
{
|
||||
"autogeneratePattern": "[a-z0-9]{15}",
|
||||
"help": "",
|
||||
"hidden": false,
|
||||
"id": "text3208210256",
|
||||
"max": 15,
|
||||
"min": 15,
|
||||
"name": "id",
|
||||
"pattern": "^[a-z0-9]+$",
|
||||
"presentable": false,
|
||||
"primaryKey": true,
|
||||
"required": true,
|
||||
"system": true,
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"autogeneratePattern": "",
|
||||
"help": "",
|
||||
"hidden": false,
|
||||
"id": "text724990059",
|
||||
"max": 0,
|
||||
"min": 0,
|
||||
"name": "title",
|
||||
"pattern": "",
|
||||
"presentable": false,
|
||||
"primaryKey": false,
|
||||
"required": true,
|
||||
"system": false,
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"autogeneratePattern": "",
|
||||
"help": "",
|
||||
"hidden": false,
|
||||
"id": "text2560465762",
|
||||
"max": 0,
|
||||
"min": 0,
|
||||
"name": "slug",
|
||||
"pattern": "^[a-z0-9]+(-[a-z0-9]+)*$",
|
||||
"presentable": false,
|
||||
"primaryKey": false,
|
||||
"required": true,
|
||||
"system": false,
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"autogeneratePattern": "",
|
||||
"help": "",
|
||||
"hidden": false,
|
||||
"id": "text436585760",
|
||||
"max": 0,
|
||||
"min": 0,
|
||||
"name": "intro",
|
||||
"pattern": "",
|
||||
"presentable": false,
|
||||
"primaryKey": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"help": "",
|
||||
"hidden": false,
|
||||
"id": "number4113142680",
|
||||
"max": null,
|
||||
"min": null,
|
||||
"name": "order",
|
||||
"onlyInt": true,
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "number"
|
||||
},
|
||||
{
|
||||
"help": "",
|
||||
"hidden": false,
|
||||
"id": "bool1001664029",
|
||||
"name": "public",
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "bool"
|
||||
}
|
||||
],
|
||||
"indexes": [
|
||||
"CREATE UNIQUE INDEX `idx_faq_topics_slug` ON `faq_topics` (`slug`)"
|
||||
],
|
||||
"created": "2026-09-23 11:59:13.899Z",
|
||||
"updated": "2026-09-23 11:59:13.899Z",
|
||||
"system": false
|
||||
},
|
||||
"faqs": {
|
||||
"id": "pbc_2923026773",
|
||||
"listRule": "",
|
||||
@@ -392,21 +492,6 @@
|
||||
"system": false,
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"autogeneratePattern": "",
|
||||
"help": "",
|
||||
"hidden": false,
|
||||
"id": "select2638274075",
|
||||
"max": 0,
|
||||
"min": 0,
|
||||
"name": "topic",
|
||||
"pattern": "",
|
||||
"presentable": false,
|
||||
"primaryKey": false,
|
||||
"required": true,
|
||||
"system": false,
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"help": "",
|
||||
"hidden": false,
|
||||
@@ -416,6 +501,33 @@
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "bool"
|
||||
},
|
||||
{
|
||||
"cascadeDelete": false,
|
||||
"collectionId": "pbc_529028860",
|
||||
"help": "",
|
||||
"hidden": false,
|
||||
"id": "relation3340346911",
|
||||
"maxSelect": 1,
|
||||
"minSelect": 0,
|
||||
"name": "topic",
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "relation"
|
||||
},
|
||||
{
|
||||
"help": "",
|
||||
"hidden": false,
|
||||
"id": "number4113142680",
|
||||
"max": null,
|
||||
"min": null,
|
||||
"name": "order",
|
||||
"onlyInt": true,
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "number"
|
||||
}
|
||||
],
|
||||
"indexes": [],
|
||||
|
||||
Reference in new issue
Block a user