Compare commits

..
10 Commits
18 changed files with 347 additions and 262 deletions

No files matched your search

+35 -28
View File
@@ -91,51 +91,58 @@ public sealed class PocketBaseClient(HttpClient httpClient)
=> GetRecordsAsync<Faq>("faqs", "topic.order,order", token, expand: "topic"); => GetRecordsAsync<Faq>("faqs", "topic.order,order", token, expand: "topic");
/// <summary> /// <summary>
/// Gets all public FAQ topics, ordered by <see cref="FaqTopic.Order"/>. /// Gets all public FAQ topics, ordered by <see cref="FaqTopic.Order"/>, each with
/// its questions expanded.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// Backs the FAQ overview, which lists the topics as links to their own pages. /// Backs the FAQ page, which shows every topic as a collapsible group with its
/// The questions are not loaded here; the topic page loads them per topic via /// questions nested inside. The <c>faqs_via_topic</c> back-relation is expanded so
/// <see cref="GetFaqTopicBySlugAsync"/>. /// one request returns the topics and their questions together; drafts among the
/// questions are dropped in <see cref="FaqTopic.Faqs"/>.
/// </remarks> /// </remarks>
/// <inheritdoc cref="GetPagesAsync" path="/param"/> /// <inheritdoc cref="GetPagesAsync" path="/param"/>
/// <returns> /// <returns>
/// The public FAQ topics, or an empty list if there are none. /// The public FAQ topics with their questions, or an empty list if there are none.
/// </returns> /// </returns>
/// <inheritdoc cref="GetPagesAsync" path="/exception"/> /// <inheritdoc cref="GetPagesAsync" path="/exception"/>
public Task<IReadOnlyList<FaqTopic>> GetFaqTopicsAsync(CancellationToken token = default) public Task<IReadOnlyList<FaqTopic>> GetFaqTopicsAsync(CancellationToken token = default)
=> GetRecordsAsync<FaqTopic>("faq_topics", "order", token); => GetRecordsAsync<FaqTopic>("faq_topics", "order", token, expand: "faqs_via_topic");
/// <summary> /// <summary>
/// Gets one public FAQ topic by its slug, with its questions expanded, or /// Checks whether PocketBase answers its health endpoint.
/// <see langword="null"/> when no public topic has that slug.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// Backs the topic's own page at <c>/faqs/{slug}</c>: it loads the single topic /// Backs the app's own <c>/health</c> endpoint, which an external monitor (Uptime
/// and, through the <c>faqs_via_topic</c> back-relation, the questions grouped /// Kuma) polls: the app answering at all proves the app is up, and this probe adds
/// under it, so the page needs one request. The slug is unique, so at most one /// whether the content source behind it is reachable, so one monitor covers both.
/// record matches. /// This calls <c>GET /api/health</c>, PocketBase's own liveness endpoint, which
/// needs no auth and touches no collection.
/// </remarks> /// </remarks>
/// <param name="slug"> /// <param name="token">
/// The topic slug from the URL, e.g. <c>"cafeteria"</c>. /// A token to cancel the probe.
/// </param> /// </param>
/// <inheritdoc cref="GetPagesAsync" path="/param[@name='token']"/>
/// <returns> /// <returns>
/// The matching public topic with its questions, or <see langword="null"/> if /// <see langword="true"/> if PocketBase answered with a success status within the
/// none matches. /// client timeout; <see langword="false"/> if it was unreachable, timed out, or
/// answered with an error status.
/// </returns> /// </returns>
/// <inheritdoc cref="GetPagesAsync" path="/exception"/> public async Task<bool> IsHealthyAsync(CancellationToken token = default)
public async Task<FaqTopic?> GetFaqTopicBySlugAsync(
string slug,
CancellationToken token = default)
{ {
var topics = await GetRecordsAsync<FaqTopic>( try
"faq_topics", {
"order", using var response = await httpClient.GetAsync(
token, new Uri("/api/health", UriKind.Relative), token);
expand: "faqs_via_topic"); return response.IsSuccessStatusCode;
return topics.FirstOrDefault( }
topic => string.Equals(topic.Slug, slug, StringComparison.OrdinalIgnoreCase)); // 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> /// <summary>
+24 -6
View File
@@ -236,16 +236,18 @@ public sealed class PocketBaseFixture : IAsyncLifetime
}); });
await CreateRecordAsync(http, "pages", new 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", 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, // Page record for the FAQ list. Route /faqs is served by FaqList, which reads
// which shadows this page). Slug matches production's "faqs" nav entry. // 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 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, order = 4, slug = "faqs", embed = Array.Empty<string>(), @public = true,
}); });
await CreateRecordAsync(http, "pages", new await CreateRecordAsync(http, "pages", new
@@ -253,6 +255,22 @@ public sealed class PocketBaseFixture : IAsyncLifetime
title = "Downloads", body = "# Downloads", location = "header", title = "Downloads", body = "# Downloads", location = "header",
order = 5, slug = "downloads", embed = Array.Empty<string>(), @public = true, 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), // 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 // so the catch-all "/{slug}" ContentPage route stays covered. It carries a
// valid location because the field is required; footer keeps it low-key. // valid location because the field is required; footer keeps it low-key.
+59 -13
View File
@@ -1,5 +1,4 @@
using System.Net; using System.Net;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.AspNetCore.Mvc.Testing;
namespace Elternbeirat.Web.Tests; namespace Elternbeirat.Web.Tests;
@@ -85,18 +84,19 @@ public sealed class RouteSmokeTests : IDisposable
} }
[Fact] [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} // /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 // catch-all content page. It reads the "faqs" page for heading and intro
// from the seeded questions, not a content page body. // 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 client = _factory.CreateClient();
var html = await client.GetStringAsync(new Uri("/faqs", UriKind.Relative)); var html = await client.GetStringAsync(new Uri("/faqs", UriKind.Relative));
html.ShouldContain("Häufige Fragen"); html.ShouldContain("FAQs"); // heading from the page title
html.ShouldContain("Wann gibt es Mittagessen?"); html.ShouldContain("Mensa und Mittagessen"); // German topic heading (a group)
html.ShouldContain("Mensa und Mittagessen"); // German topic heading html.ShouldContain("Wann gibt es Mittagessen?"); // a question nested in the group
} }
[Fact] [Fact]
@@ -129,16 +129,62 @@ public sealed class RouteSmokeTests : IDisposable
} }
[Fact] [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 // /patrons is a catch-all content page (the /{Slug} route). Assert it renders
// shows its body plus the FAQ teaser -- proving the ContentPage embed path. // its Markdown body, proving the ContentPage path works for an arbitrary slug.
var client = _factory.CreateClient(); var client = _factory.CreateClient();
var html = await client.GetStringAsync(new Uri("/patrons", UriKind.Relative)); var html = await client.GetStringAsync(new Uri("/patrons", UriKind.Relative));
html.ShouldContain("Förderverein"); // the page body html.ShouldContain("Förderverein"); // the page body, rendered from Markdown
html.ShouldContain("Häufige Fragen"); // faqs embed heading }
html.ShouldContain("Wann gibt es Mittagessen?"); // a seeded question
[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) @foreach (var ev in _upcomingEvents)
{ {
<li> <li>
<EventDate Value="ev" /> <a class="card-link" href="/events">
<span class="event-title">@ev.Title</span> <span class="event-title">@ev.Title</span>
<EventDate Value="ev" />
@if (!string.IsNullOrWhiteSpace(ev.Location)) @if (!string.IsNullOrWhiteSpace(ev.Location))
{ {
<span class="event-location">@ev.Location</span> <span class="event-location">@ev.Location</span>
} }
</a>
</li> </li>
} }
</ul> </ul>
<p class="embed-more"><a href="/events">Alle Termine <span aria-hidden="true">&rarr;</span></a></p>
</section> </section>
} }
@@ -7,11 +7,12 @@
@foreach (var post in _latest) @foreach (var post in _latest)
{ {
<li> <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" /> <PostDate Value="post.Date" />
</a>
</li> </li>
} }
</ul> </ul>
<p class="embed-more"><a href="/posts">Alle Beiträge <span aria-hidden="true">&rarr;</span></a></p>
</section> </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" @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) @if (_unavailable)
{ {
@@ -28,8 +39,9 @@ else
@foreach (var ev in _upcomingEvents) @foreach (var ev in _upcomingEvents)
{ {
<li> <li>
<EventDate Value="ev" /> <a class="card-link" href="/events">
<span class="event-title">@ev.Title</span> <span class="event-title">@ev.Title</span>
<EventDate Value="ev" />
@if (!string.IsNullOrWhiteSpace(ev.Location)) @if (!string.IsNullOrWhiteSpace(ev.Location))
{ {
<span class="event-location">@ev.Location</span> <span class="event-location">@ev.Location</span>
@@ -38,6 +50,7 @@ else
{ {
<p class="event-note">@ev.Note</p> <p class="event-note">@ev.Note</p>
} }
</a>
</li> </li>
} }
</ul> </ul>
@@ -50,12 +63,14 @@ else
@foreach (var ev in _pastEvents) @foreach (var ev in _pastEvents)
{ {
<li> <li>
<EventDate Value="ev" /> <a class="card-link" href="/events">
<span class="event-title">@ev.Title</span> <span class="event-title">@ev.Title</span>
<EventDate Value="ev" />
@if (!string.IsNullOrWhiteSpace(ev.Location)) @if (!string.IsNullOrWhiteSpace(ev.Location))
{ {
<span class="event-location">@ev.Location</span> <span class="event-location">@ev.Location</span>
} }
</a>
</li> </li>
} }
</ul> </ul>
@@ -23,6 +23,11 @@ public partial class EventList
[CascadingParameter] [CascadingParameter]
private HttpContext? HttpContext { get; set; } 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> _upcomingEvents = [];
private IReadOnlyList<Event> _pastEvents = []; private IReadOnlyList<Event> _pastEvents = [];
@@ -31,12 +36,19 @@ public partial class EventList
// 503 this sets on the response. // 503 this sets on the response.
private bool _unavailable; 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() protected override async Task OnInitializedAsync()
{ {
IReadOnlyList<Event> events; IReadOnlyList<Event> events;
try 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) catch (PocketBaseUnavailableException exception)
{ {
+25 -7
View File
@@ -1,8 +1,19 @@
@page "/faqs" @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) @if (_unavailable)
{ {
@@ -14,16 +25,23 @@ else if (_topics.Count == 0)
} }
else else
{ {
<ul class="faq-topic-list">
@foreach (var topic in _topics) @foreach (var topic in _topics)
{ {
<li> <details class="faq-topic">
<h2><a href="@($"/faqs/{topic.Slug}")">@topic.Title</a></h2> <summary>@topic.Title</summary>
@if (!string.IsNullOrWhiteSpace(topic.Intro)) @if (!string.IsNullOrWhiteSpace(topic.Intro))
{ {
<div class="faq-topic-intro">@((MarkupString)Markdown.ToHtml(topic.Intro))</div> <div class="faq-topic-intro">@((MarkupString)Markdown.ToHtml(topic.Intro))</div>
} }
</li> @foreach (var faq in topic.Faqs)
{
<details class="faq-item">
<summary>@faq.Question</summary>
<div class="faq-answer">
@((MarkupString)Markdown.ToHtml(faq.Answer))
</div>
</details>
}
</details>
} }
</ul>
} }
@@ -23,6 +23,11 @@ public partial class FaqList
[CascadingParameter] [CascadingParameter]
private HttpContext? HttpContext { get; set; } private HttpContext? HttpContext { get; set; }
// 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 = []; private IReadOnlyList<FaqTopic> _topics = [];
// Set when the content source could not be reached or read. The markup then // Set when the content source could not be reached or read. The markup then
@@ -30,11 +35,19 @@ public partial class FaqList
// the 503 this sets on the response. // the 503 this sets on the response.
private bool _unavailable; 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() protected override async Task OnInitializedAsync()
{ {
try try
{ {
_topics = await PocketBase.GetFaqTopicsAsync(HttpContext?.RequestAborted ?? CancellationToken.None); 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) catch (PocketBaseUnavailableException exception)
{ {
@@ -1,30 +0,0 @@
@page "/faqs/{Slug}"
@if (_unavailable)
{
<PageTitle>Vorübergehend nicht erreichbar</PageTitle>
<p>Das Thema ist gerade nicht erreichbar. Bitte später erneut versuchen.</p>
}
else if (_topic is not null)
{
<PageTitle>@_topic.Title</PageTitle>
<nav>
<a href="/faqs">&larr; Alle Themen</a>
</nav>
<h1>@_topic.Title</h1>
@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>
<div class="faq-answer">
@((MarkupString)Markdown.ToHtml(faq.Answer))
</div>
</details>
}
}
@* No topic and not unavailable: the slug was unknown; NavigationManager.NotFound()
already ran in OnParametersSetAsync and renders the not-found page. *@
@@ -1,63 +0,0 @@
using Elternbeirat.Contracts;
using Elternbeirat.PocketBase;
using Elternbeirat.Web.Shared;
using Microsoft.AspNetCore.Components;
namespace Elternbeirat.Web.Features.Faqs;
public partial class FaqTopicDetail
{
[LoggerMessage(
Level = LogLevel.Error,
Message = "Could not load FAQ topic '{Slug}' from PocketBase; content source is unavailable.")]
private static partial void LogSourceUnavailable(
ILogger logger,
string slug,
Exception exception);
[Parameter]
public string Slug { get; set; } = string.Empty;
[Inject]
private PocketBaseClient PocketBase { get; set; } = null!;
[Inject]
private ILogger<FaqTopicDetail> Logger { get; set; } = null!;
[Inject]
private NavigationManager Navigation { get; set; } = null!;
[CascadingParameter]
private HttpContext? HttpContext { get; set; }
private FaqTopic? _topic;
// Set when the content source could not be reached or read. The markup then
// shows a "temporarily unavailable" note instead of a not-found page, matching
// the 503 this sets on the response.
private bool _unavailable;
protected override async Task OnParametersSetAsync()
{
try
{
_topic = await PocketBase.GetFaqTopicBySlugAsync(
Slug,
HttpContext?.RequestAborted ?? CancellationToken.None);
}
catch (PocketBaseUnavailableException exception)
{
// The site works, its data store is down: 503, not 404.
LogSourceUnavailable(Logger, Slug, exception);
_unavailable = true;
SourceUnavailable.SetStatus(HttpContext);
return;
}
if (_topic is null)
{
// The source answered, but there is no topic with this slug: a genuine 404.
Navigation.NotFound();
}
}
}
+16 -3
View File
@@ -1,8 +1,19 @@
@page "/posts" @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) @if (_unavailable)
{ {
@@ -18,8 +29,10 @@ else
@foreach (var post in _posts) @foreach (var post in _posts)
{ {
<li> <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" /> <PostDate Value="post.Date" />
</a>
</li> </li>
} }
</ul> </ul>
@@ -23,6 +23,11 @@ public partial class PostList
[CascadingParameter] [CascadingParameter]
private HttpContext? HttpContext { get; set; } 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 = []; private IReadOnlyList<Post> _posts = [];
// Set when the content source could not be reached or read. The markup then // 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. // 503 this sets on the response.
private bool _unavailable; 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() protected override async Task OnInitializedAsync()
{ {
try 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) catch (PocketBaseUnavailableException exception)
{ {
+31 -21
View File
@@ -6,52 +6,50 @@ using Microsoft.AspNetCore.HttpOverrides;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
// Add services to the container.
builder.Services.AddRazorComponents(); builder.Services.AddRazorComponents();
// The typed client reads content from PocketBase over its REST API. The base URL // The typed client reads content from PocketBase over its REST API. The base URL
// comes from configuration: appsettings for local dev, the PocketBase__BaseUrl // comes from config: appsettings for local dev, the PocketBase__BaseUrl env var
// env var on the server (see compose.yaml). // on the server (see compose.yaml).
var pocketBaseUrl = builder.Configuration["PocketBase:BaseUrl"] var pocketBaseUrl = builder.Configuration["PocketBase:BaseUrl"]
?? throw new InvalidOperationException("PocketBase:BaseUrl is not configured."); ?? throw new InvalidOperationException("PocketBase:BaseUrl is not configured.");
builder.Services.AddHttpClient<PocketBaseClient>(client => 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(); var app = builder.Build();
// NPM terminates TLS and is the only way to reach the container (no port // NPM sits in front of the app and handles HTTPS. Without this line the app would
// mapping in production). Without UseForwardedHeaders the app sees every request // think every request is plain HTTP and would see NPM's IP, not the visitor's.
// as HTTP and with the proxy IP instead of the client IP. // KnownNetworks/KnownProxies are left empty on purpose: only NPM reaches the app.
// KnownNetworks/KnownProxies are deliberately empty because only NPM reaches
// the container.
app.UseForwardedHeaders( app.UseForwardedHeaders(
new ForwardedHeadersOptions new ForwardedHeadersOptions
{ {
ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto
}); });
// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment() is false) if (app.Environment.IsDevelopment() is false)
{ {
app.UseExceptionHandler("/Error", createScopeForErrors: true); app.UseExceptionHandler("/Error", createScopeForErrors: true);
// No UseHsts() and no UseHttpsRedirection(): NPM sets HSTS and terminates // No UseHsts() and no UseHttpsRedirection() here: NPM already handles HTTPS.
// TLS. Both here would create a redirect loop behind the proxy. // Adding them behind NPM would send the browser into a redirect loop.
} }
// No UseStatusCodePagesWithReExecute: the Router renders the NotFoundPage // Don't add UseStatusCodePagesWithReExecute here. Blazor already shows its own
// (Pages.NotFound) in process for both an unmatched route and an explicit // not-found page. That middleware would catch *every* error code, so when PocketBase
// NavigationManager.NotFound() from a component (see Routes.razor). Re-executing // is down (a 503) it would wrongly show "not found" instead of our own message.
// 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.
app.UseAntiforgery(); app.UseAntiforgery();
app.MapStaticAssets(); app.MapStaticAssets();
app.MapRazorComponents<App>(); app.MapRazorComponents<App>();
// Subscribable calendar feed of all events. A minimal API endpoint rather than // Calendar feed people can subscribe to. It returns calendar data, not a web page,
// a Razor page because it returns text/calendar, not HTML. Reads from PocketBase // so it is a plain endpoint, not a Razor page. If PocketBase is down it returns an
// per request; an unreachable source yields an empty calendar, not a 500. // empty calendar rather than an error.
app.MapGet("/events.ics", async (PocketBaseClient pocketBase, CancellationToken token) => app.MapGet("/events.ics", async (PocketBaseClient pocketBase, CancellationToken token) =>
{ {
IReadOnlyList<Event> events; 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"); 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(); app.Run();
+68 -71
View File
@@ -206,8 +206,7 @@ h1:focus {
lifted off the tinted page background with a soft shadow and a gentle hover. */ lifted off the tinted page background with a soft shadow and a gentle hover. */
.post-list, .post-list,
.event-list, .event-list {
.faq-topic-list {
list-style: none; list-style: none;
padding: 0; padding: 0;
margin: 0; margin: 0;
@@ -216,31 +215,73 @@ h1:focus {
} }
.post-list li, .post-list li,
.event-list li, .event-list li {
.faq-topic-list li { background: var(--color-bg);
border-radius: var(--radius);
box-shadow: var(--shadow);
transition: box-shadow var(--transition);
}
.post-list li:hover,
.event-list li:hover {
box-shadow: var(--shadow-hover);
}
/* 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;
}
/* 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); background: var(--color-bg);
border-radius: var(--radius); border-radius: var(--radius);
box-shadow: var(--shadow); box-shadow: var(--shadow);
padding: var(--space-3) var(--space-4); padding: var(--space-3) var(--space-4);
transition: box-shadow var(--transition), transform var(--transition); margin-bottom: var(--space-3);
transition: box-shadow var(--transition);
} }
.post-list li:hover, .faq-topic:hover {
.event-list li:hover,
.faq-topic-list li:hover {
box-shadow: var(--shadow-hover); box-shadow: var(--shadow-hover);
transform: translateY(-2px);
} }
/* FAQ topics: each card is a link to the topic's own page, with an optional intro .faq-topic > summary {
below the heading. The heading matches the post/event card titles. */ cursor: pointer;
.faq-topic-list h2 {
margin: 0;
font-size: 1.15rem; 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-list h2 a { .faq-topic > summary::-webkit-details-marker {
text-decoration: none; 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 { .faq-topic-intro {
@@ -248,41 +289,28 @@ h1:focus {
color: var(--color-text-soft); color: var(--color-text-soft);
} }
/* Posts */ /* Post and event cards are built the same way, so they read as one style on the
.post-list a { home page where both appear: the title leads in the brand colour (the whole card
font-size: 1.15rem; is the link, so the title itself is plain text), the date sits quietly beneath. */
font-weight: 600; .post-title,
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);
}
/* Events: the date leads in the warm accent colour. */
.event-list time {
display: block;
color: var(--color-accent-dark);
font-weight: 600;
font-size: 0.9rem;
}
.event-title { .event-title {
display: block; display: block;
margin-top: var(--space-1);
font-size: 1.15rem; font-size: 1.15rem;
font-weight: 600; font-weight: 600;
color: var(--color-brand); color: var(--color-brand);
} }
.event-location::before { /* The date's small, softened look comes from PostDate.razor.css / EventDate; here
content: " · "; only the shared spacing: nudge it down from the title above. */
color: var(--color-text-soft); .post-list time,
.event-list time {
margin-top: var(--space-1);
} }
/* The location sits on its own quiet line beneath the date. */
.event-location { .event-location {
display: block;
margin-top: var(--space-1);
color: var(--color-text-soft); color: var(--color-text-soft);
} }
@@ -299,7 +327,6 @@ h1:focus {
} }
.event-list-past li:hover { .event-list-past li:hover {
transform: none;
box-shadow: none; box-shadow: none;
} }
@@ -357,33 +384,3 @@ h1:focus {
padding-top: var(--space-2); padding-top: var(--space-2);
color: var(--color-text); 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
View File
@@ -3,8 +3,12 @@
# #
# The two `ports:` blocks below are TEMPORARY. The target state is NPM in front: # 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 # 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 # PocketBase through an NPM subdomain (#9). NPM still needs a target address and
# NPM is not set up yet, the ports are the only way onto the site and the admin UI. # 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 # For local development use the dev overlay on top, which adds host ports and builds
# the web image from source: # the web image from source:
@@ -31,8 +35,9 @@ services:
- /mnt/user/appdata/pocketbase/pb_data:/pb_data - /mnt/user/appdata/pocketbase/pb_data:/pb_data
ports: ports:
# TEMPORARY: exposes the admin UI on the LAN (http://<unraid>:8090/_/) while # 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 is not in front yet. Replace once the editors reach PocketBase through an
# NPM subdomain (#9) -- until then the admin has no other way in. # 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" - "8090:8090"
healthcheck: healthcheck:
# The image ships this endpoint; the web app waits for it to pass. # The image ships this endpoint; the web app waits for it to pass.
@@ -55,6 +60,7 @@ services:
PocketBase__BaseUrl: http://eb-pocketbase:8090 PocketBase__BaseUrl: http://eb-pocketbase:8090
ports: ports:
# TEMPORARY: reach the site on the LAN (http://<unraid>:5000) while NPM is not # 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 # in front yet. Replace once NPM terminates TLS and fronts the site (#9): join
# and is the only path in (#9). # 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" - "5000:8080"
+3 -3
View File
@@ -21,11 +21,11 @@ migrate((app) => {
const schema = JSON.parse(toString($os.readFile("/pb_migrations/collections_schema.json"))) const schema = JSON.parse(toString($os.readFile("/pb_migrations/collections_schema.json")))
const records = { 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"}]
, ,
// Topics own the grouping and the order of the FAQ page; a question links to // 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. // its topic below via the "topic" relation, resolved by slug at seed time.