Merge pull request 'Design/home hero' (#54) from design/home-hero into main
Build and push image / build (push) Successful in 1m53s
Build and push image / build (push) Successful in 1m53s
Reviewed-on: #54
This commit was merged in pull request #54.
This commit is contained in:
commit
d328adac5c
24 files changed
+1303
-65
No files matched your search
@@ -0,0 +1,13 @@
|
|||||||
|
namespace Elternbeirat.Web.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A clock that always shows the same moment, so tests can decide what "today" is
|
||||||
|
/// (e.g. before or after the fixture's events). Hand-written instead of
|
||||||
|
/// Microsoft.Extensions.TimeProvider.Testing: overriding one method does not justify
|
||||||
|
/// a new package.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="now">The moment the clock shows.</param>
|
||||||
|
internal sealed class FixedTimeProvider(DateTimeOffset now) : TimeProvider
|
||||||
|
{
|
||||||
|
public override DateTimeOffset GetUtcNow() => now;
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
using Elternbeirat.Web.Features.Home;
|
||||||
|
|
||||||
|
namespace Elternbeirat.Web.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Unit tests for taking the home page's body apart into heading, intro and
|
||||||
|
/// buttons. Pure logic, no PocketBase needed.
|
||||||
|
/// </summary>
|
||||||
|
public class HomeIntroTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Body_splits_into_title_intro_and_buttons()
|
||||||
|
{
|
||||||
|
var intro = HomeIntro.Parse(
|
||||||
|
"# Willkommen\n\nSchön, dass Sie da sind.\n\n[Kommende Termine](/events)\n[Kontakt aufnehmen](/contact)");
|
||||||
|
|
||||||
|
intro.Title.ShouldBe("Willkommen");
|
||||||
|
intro.IntroHtml.ShouldBe("<p>Schön, dass Sie da sind.</p>\n");
|
||||||
|
intro.Links.ShouldBe(
|
||||||
|
[
|
||||||
|
new HomeLink("/events", "Kommende Termine"),
|
||||||
|
new HomeLink("/contact", "Kontakt aufnehmen"),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(null)]
|
||||||
|
[InlineData("")]
|
||||||
|
[InlineData(" ")]
|
||||||
|
public void Empty_body_gives_the_fallback(string? markdown) =>
|
||||||
|
HomeIntro.Parse(markdown).ShouldBe(HomeIntro.Fallback);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Body_without_heading_keeps_the_fallback_title_and_all_text()
|
||||||
|
{
|
||||||
|
var intro = HomeIntro.Parse("Nur ein Absatz.");
|
||||||
|
|
||||||
|
intro.Title.ShouldBe(HomeIntro.FallbackTitle);
|
||||||
|
intro.IntroHtml.ShouldContain("Nur ein Absatz.");
|
||||||
|
intro.Links.ShouldBeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Last_paragraph_with_other_text_stays_intro()
|
||||||
|
{
|
||||||
|
// "oder" would get lost in a button row, so the paragraph stays text.
|
||||||
|
var intro = HomeIntro.Parse("# Hallo\n\n[Termine](/events) oder [Kontakt](/contact)");
|
||||||
|
|
||||||
|
intro.Links.ShouldBeEmpty();
|
||||||
|
intro.IntroHtml.ShouldContain("href=\"/events\"");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Link_paragraph_that_is_not_last_stays_intro()
|
||||||
|
{
|
||||||
|
var intro = HomeIntro.Parse("# Hallo\n\n[Termine](/events)\n\nSchlusssatz.");
|
||||||
|
|
||||||
|
intro.Links.ShouldBeEmpty();
|
||||||
|
intro.IntroHtml.ShouldContain("Schlusssatz.");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Heading_markup_is_reduced_to_its_text()
|
||||||
|
{
|
||||||
|
HomeIntro.Parse("# Willkommen beim **Elternbeirat**").Title.ShouldBe("Willkommen beim Elternbeirat");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -78,4 +78,51 @@ public class MarkdownTests
|
|||||||
html.ShouldContain($"<li class=\"{Markdown.LoneLinkClass}\"><a href=\"/dokumente/antrag.pdf\">");
|
html.ShouldContain($"<li class=\"{Markdown.LoneLinkClass}\"><a href=\"/dokumente/antrag.pdf\">");
|
||||||
html.ShouldContain("<li>Text mit");
|
html.ShouldContain("<li>Text mit");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void First_sentence_skips_the_heading_and_drops_the_markdown()
|
||||||
|
{
|
||||||
|
var sentence = Markdown.FirstSentence(
|
||||||
|
"# Vorstand\n\nWir sind **sieben** [Eltern](/board). Der Rest folgt.");
|
||||||
|
|
||||||
|
sentence.ShouldBe("Wir sind sieben Eltern.");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("Wir helfen z. B. bei Anträgen. Mehr dazu unten.", "Wir helfen z. B. bei Anträgen.")]
|
||||||
|
[InlineData("Gewählt am 13. November 2025. Danach mehr.", "Gewählt am 13. November 2025.")]
|
||||||
|
[InlineData("Haben Sie Fragen? Schreiben Sie uns.", "Haben Sie Fragen?")]
|
||||||
|
[InlineData("Ohne Satzzeichen am Ende", "Ohne Satzzeichen am Ende")]
|
||||||
|
public void First_sentence_ends_at_a_real_sentence_end(string markdown, string expected) =>
|
||||||
|
Markdown.FirstSentence(markdown).ShouldBe(expected);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void First_sentence_joins_soft_line_breaks_with_a_space()
|
||||||
|
{
|
||||||
|
// Editors wrap long lines; the wrap must not glue two words together.
|
||||||
|
Markdown.FirstSentence("Erste Zeile\nzweite Zeile.").ShouldBe("Erste Zeile zweite Zeile.");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void First_sentence_drops_a_colon_that_leads_into_a_list() =>
|
||||||
|
Markdown.FirstSentence("Formulare zum Herunterladen:\n\n- Antrag")
|
||||||
|
.ShouldBe("Formulare zum Herunterladen");
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void First_sentence_is_never_cut_short()
|
||||||
|
{
|
||||||
|
// Long teasers are clamped by the tile's CSS, not by a character count.
|
||||||
|
var sentence = string.Join(' ', Enumerable.Repeat("Wort", 60)) + ".";
|
||||||
|
|
||||||
|
Markdown.FirstSentence(sentence).ShouldBe(sentence);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(null)]
|
||||||
|
[InlineData("")]
|
||||||
|
[InlineData("# Nur eine Überschrift")]
|
||||||
|
[InlineData("- nur\n- eine Liste")]
|
||||||
|
[InlineData("::: hinweis\nText im Baustein.\n:::")]
|
||||||
|
public void First_sentence_is_empty_without_a_top_level_paragraph(string? markdown) =>
|
||||||
|
Markdown.FirstSentence(markdown).ShouldBe("");
|
||||||
}
|
}
|
||||||
@@ -226,7 +226,11 @@ public sealed class PocketBaseFixture : IAsyncLifetime
|
|||||||
// links home too, but the nav entry is the single source of the home link.
|
// links home too, but the nav entry is the single source of the home link.
|
||||||
await CreateRecordAsync(http, "pages", new
|
await CreateRecordAsync(http, "pages", new
|
||||||
{
|
{
|
||||||
title = "Start", body = "# Willkommen", location = "header",
|
// The body drives the home hero: "# heading" -> h1, the paragraph -> intro,
|
||||||
|
// the last paragraph of only links -> the button row.
|
||||||
|
title = "Start",
|
||||||
|
body = "# Willkommen\n\nSchön, dass Sie da sind.\n\n[Kommende Termine](/events)\n[Kontakt aufnehmen](/contact)",
|
||||||
|
location = "header",
|
||||||
order = 1, slug = "home", embed = new[] { "posts", "events" }, @public = true,
|
order = 1, slug = "home", embed = new[] { "posts", "events" }, @public = true,
|
||||||
});
|
});
|
||||||
await CreateRecordAsync(http, "pages", new
|
await CreateRecordAsync(http, "pages", new
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
using System.Net;
|
using System.Net;
|
||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
using Microsoft.AspNetCore.Mvc.Testing;
|
using Microsoft.AspNetCore.Mvc.Testing;
|
||||||
|
using Microsoft.AspNetCore.TestHost;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
namespace Elternbeirat.Web.Tests;
|
namespace Elternbeirat.Web.Tests;
|
||||||
|
|
||||||
@@ -29,10 +31,25 @@ public sealed partial class RouteSmokeTests : IDisposable
|
|||||||
// the value from appsettings. UseSetting (not ConfigureAppConfiguration)
|
// the value from appsettings. UseSetting (not ConfigureAppConfiguration)
|
||||||
// because it wins over appsettings.Development.json, which otherwise pins the
|
// because it wins over appsettings.Development.json, which otherwise pins the
|
||||||
// client to localhost:8090 while the test container uses a random port.
|
// client to localhost:8090 while the test container uses a random port.
|
||||||
_factory = _baseFactory.WithWebHostBuilder(builder =>
|
_factory = WithClock(
|
||||||
builder.UseSetting("PocketBase:BaseUrl", pocketBase.BaseUrl.ToString()));
|
_baseFactory.WithWebHostBuilder(builder =>
|
||||||
|
builder.UseSetting("PocketBase:BaseUrl", pocketBase.BaseUrl.ToString())),
|
||||||
|
BeforeFixtureEvents);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// "Now" for the default factory: a week before the fixture's first event, so
|
||||||
|
/// "upcoming" stays true no matter when the tests run.
|
||||||
|
/// </summary>
|
||||||
|
private static readonly DateTimeOffset BeforeFixtureEvents = new(2026, 10, 1, 10, 0, 0, TimeSpan.Zero);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Wraps <paramref name="factory"/> so the app's clock shows <paramref name="now"/>.
|
||||||
|
/// </summary>
|
||||||
|
private static WebApplicationFactory<Program> WithClock(WebApplicationFactory<Program> factory, DateTimeOffset now) =>
|
||||||
|
factory.WithWebHostBuilder(builder =>
|
||||||
|
builder.ConfigureTestServices(services => services.AddSingleton<TimeProvider>(new FixedTimeProvider(now))));
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
_factory.Dispose();
|
_factory.Dispose();
|
||||||
@@ -261,6 +278,56 @@ public sealed partial class RouteSmokeTests : IDisposable
|
|||||||
response.StatusCode.ShouldBe(HttpStatusCode.ServiceUnavailable);
|
response.StatusCode.ShouldBe(HttpStatusCode.ServiceUnavailable);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Home_hero_takes_title_and_buttons_from_the_body()
|
||||||
|
{
|
||||||
|
var client = _factory.CreateClient();
|
||||||
|
|
||||||
|
var html = await client.GetStringAsync(new Uri("/", UriKind.Relative));
|
||||||
|
|
||||||
|
html.ShouldContain(">Willkommen</h1>");
|
||||||
|
html.ShouldNotContain(">Elternbeirat der IGMH</h1>"); // the fallback title
|
||||||
|
html.ShouldContain("""<a class="btn btn-primary" href="/events">""");
|
||||||
|
html.ShouldContain("""<a class="btn btn-secondary" href="/contact">""");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Home_tiles_are_exactly_the_public_header_pages()
|
||||||
|
{
|
||||||
|
var client = _factory.CreateClient();
|
||||||
|
|
||||||
|
var html = await client.GetStringAsync(new Uri("/", UriKind.Relative));
|
||||||
|
|
||||||
|
var tiles = HomeTiles().Match(html);
|
||||||
|
tiles.Success.ShouldBeTrue();
|
||||||
|
Href().Matches(tiles.Value).Select(match => match.Groups[1].Value)
|
||||||
|
.ShouldBe(["/board", "/patrons", "/faqs", "/downloads", "/posts", "/events"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Home_shows_the_next_event_when_one_is_upcoming()
|
||||||
|
{
|
||||||
|
var client = _factory.CreateClient();
|
||||||
|
|
||||||
|
var html = await client.GetStringAsync(new Uri("/", UriKind.Relative));
|
||||||
|
|
||||||
|
html.ShouldContain("Nächster Termin");
|
||||||
|
html.ShouldContain("Elternbeiratssitzung");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Home_hides_the_next_event_when_none_is_upcoming()
|
||||||
|
{
|
||||||
|
// After the fixture's last event nothing is upcoming; the card must vanish
|
||||||
|
// instead of showing an empty frame.
|
||||||
|
await using var factory = WithClock(_factory, new DateTimeOffset(2027, 1, 1, 10, 0, 0, TimeSpan.Zero));
|
||||||
|
var client = factory.CreateClient();
|
||||||
|
|
||||||
|
var html = await client.GetStringAsync(new Uri("/", UriKind.Relative));
|
||||||
|
|
||||||
|
html.ShouldNotContain("Nächster Termin");
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Matches a resource reference that points at an absolute URL, with or without
|
/// Matches a resource reference that points at an absolute URL, with or without
|
||||||
/// scheme (<c>https://</c>, <c>http://</c> or protocol-relative <c>//</c>): the
|
/// scheme (<c>https://</c>, <c>http://</c> or protocol-relative <c>//</c>): the
|
||||||
@@ -282,4 +349,11 @@ public sealed partial class RouteSmokeTests : IDisposable
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
[GeneratedRegex(@"\bhref=""([^""]*)""")]
|
[GeneratedRegex(@"\bhref=""([^""]*)""")]
|
||||||
private static partial Regex Href();
|
private static partial Regex Href();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Matches the home page's tile section, from its opening tag to the closing
|
||||||
|
/// one. It nests no other <c><section></c>.
|
||||||
|
/// </summary>
|
||||||
|
[GeneratedRegex("""<section class="home-tiles".*?</section>""", RegexOptions.Singleline)]
|
||||||
|
private static partial Regex HomeTiles();
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
using Elternbeirat.Web.Features.Home;
|
||||||
|
using Elternbeirat.Web.Shared;
|
||||||
|
|
||||||
|
namespace Elternbeirat.Web.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Unit tests for the slug-to-icon mapping of the home page tiles. Pure logic, no
|
||||||
|
/// PocketBase needed.
|
||||||
|
/// </summary>
|
||||||
|
public class TileIconsTests
|
||||||
|
{
|
||||||
|
[Theory]
|
||||||
|
[InlineData("board", "users")]
|
||||||
|
[InlineData("faqs", "help-circle")]
|
||||||
|
[InlineData("events", "calendar")]
|
||||||
|
public void Known_slug_gets_its_icon(string slug, string icon) =>
|
||||||
|
TileIcons.For(slug).ShouldBe(icon);
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("schulweg")] // a page an editor just created
|
||||||
|
[InlineData("Board")] // slugs are lowercase; anything else is unknown
|
||||||
|
[InlineData("")]
|
||||||
|
public void Unknown_slug_gets_the_default_icon(string slug) =>
|
||||||
|
TileIcons.For(slug).ShouldBe(TileIcons.Default);
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("board")]
|
||||||
|
[InlineData("patrons")]
|
||||||
|
[InlineData("faqs")]
|
||||||
|
[InlineData("downloads")]
|
||||||
|
[InlineData("posts")]
|
||||||
|
[InlineData("events")]
|
||||||
|
[InlineData("contact")]
|
||||||
|
[InlineData("unbekannt")] // the default
|
||||||
|
public void Every_icon_exists_in_the_icon_set(string slug) =>
|
||||||
|
IconSet.Find(TileIcons.For(slug)).ShouldNotBeNull(); // a typo would render no icon at all
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
using Elternbeirat.Contracts;
|
||||||
|
using Elternbeirat.Web.Features.Events;
|
||||||
|
using Elternbeirat.Web.Shared;
|
||||||
|
|
||||||
|
namespace Elternbeirat.Web.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Unit tests for the "what is coming up?" rule and the Berlin day behind it. Pure
|
||||||
|
/// logic, no PocketBase needed.
|
||||||
|
/// </summary>
|
||||||
|
public class UpcomingEventsTests
|
||||||
|
{
|
||||||
|
private static Event Event(string title, DateTime start) =>
|
||||||
|
new() { Id = "test", Title = title, Start = start };
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Keeps_today_and_later_ordered_by_start()
|
||||||
|
{
|
||||||
|
var events = new[]
|
||||||
|
{
|
||||||
|
Event("Basar", new DateTime(2026, 11, 22, 9, 0, 0)),
|
||||||
|
Event("Gestern", new DateTime(2026, 9, 30, 19, 30, 0)),
|
||||||
|
Event("Heute früh", new DateTime(2026, 10, 1, 7, 0, 0)), // already begun, still today
|
||||||
|
Event("Sitzung", new DateTime(2026, 10, 8, 19, 30, 0)),
|
||||||
|
};
|
||||||
|
|
||||||
|
var upcoming = UpcomingEvents.From(events, new DateOnly(2026, 10, 1));
|
||||||
|
|
||||||
|
upcoming.Select(@event => @event.Title).ShouldBe(["Heute früh", "Sitzung", "Basar"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Nothing_upcoming_gives_an_empty_list() =>
|
||||||
|
UpcomingEvents.From([Event("Vorbei", new DateTime(2026, 1, 1))], new DateOnly(2026, 10, 1))
|
||||||
|
.ShouldBeEmpty();
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Berlin_day_starts_before_the_UTC_day()
|
||||||
|
{
|
||||||
|
// 22:30 UTC on 30 September is 00:30 on 1 October in Berlin (summer time).
|
||||||
|
var time = new FixedTimeProvider(new DateTimeOffset(2026, 9, 30, 22, 30, 0, TimeSpan.Zero));
|
||||||
|
|
||||||
|
BerlinClock.Today(time).ShouldBe(new DateOnly(2026, 10, 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,7 +27,7 @@
|
|||||||
<span class="nav-toggle-bar"></span>
|
<span class="nav-toggle-bar"></span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<nav class="main-nav">
|
<nav class="main-nav" aria-label="Hauptmenü">
|
||||||
@* The home link comes from PocketBase like every other entry (the
|
@* The home link comes from PocketBase like every other entry (the
|
||||||
"home" page carries location "header"), so it is rendered by the
|
"home" page carries location "header"), so it is rendered by the
|
||||||
loop below -- no separate hard-coded home link, which would show
|
loop below -- no separate hard-coded home link, which would show
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
using Elternbeirat.Contracts;
|
using Elternbeirat.Contracts;
|
||||||
using Elternbeirat.PocketBase;
|
using Elternbeirat.PocketBase;
|
||||||
|
using Elternbeirat.Web.Features.Events;
|
||||||
|
using Elternbeirat.Web.Shared;
|
||||||
using Microsoft.AspNetCore.Components;
|
using Microsoft.AspNetCore.Components;
|
||||||
|
|
||||||
namespace Elternbeirat.Web.Features.Embeds;
|
namespace Elternbeirat.Web.Features.Embeds;
|
||||||
@@ -22,6 +24,9 @@ public partial class EventsEmbed
|
|||||||
[Inject]
|
[Inject]
|
||||||
private ILogger<EventsEmbed> Logger { get; set; } = null!;
|
private ILogger<EventsEmbed> Logger { get; set; } = null!;
|
||||||
|
|
||||||
|
[Inject]
|
||||||
|
private TimeProvider Time { get; set; } = null!;
|
||||||
|
|
||||||
[CascadingParameter]
|
[CascadingParameter]
|
||||||
private HttpContext? HttpContext { get; set; }
|
private HttpContext? HttpContext { get; set; }
|
||||||
|
|
||||||
@@ -44,13 +49,6 @@ public partial class EventsEmbed
|
|||||||
|
|
||||||
// Teaser: the soonest upcoming events, earliest first. An event earlier
|
// Teaser: the soonest upcoming events, earliest first. An event earlier
|
||||||
// today still counts as upcoming.
|
// today still counts as upcoming.
|
||||||
var today = DateOnly.FromDateTime(DateTime.Today);
|
_upcomingEvents = [.. UpcomingEvents.From(events, BerlinClock.Today(Time)).Take(TeaserCount)];
|
||||||
_upcomingEvents =
|
|
||||||
[
|
|
||||||
.. events
|
|
||||||
.Where(@event => DateOnly.FromDateTime(@event.Start) >= today)
|
|
||||||
.OrderBy(@event => @event.Start)
|
|
||||||
.Take(TeaserCount)
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -20,6 +20,9 @@ public partial class EventList
|
|||||||
[Inject]
|
[Inject]
|
||||||
private ILogger<EventList> Logger { get; set; } = null!;
|
private ILogger<EventList> Logger { get; set; } = null!;
|
||||||
|
|
||||||
|
[Inject]
|
||||||
|
private TimeProvider Time { get; set; } = null!;
|
||||||
|
|
||||||
[CascadingParameter]
|
[CascadingParameter]
|
||||||
private HttpContext? HttpContext { get; set; }
|
private HttpContext? HttpContext { get; set; }
|
||||||
|
|
||||||
@@ -62,13 +65,8 @@ public partial class EventList
|
|||||||
|
|
||||||
// Split into upcoming and past by date, so an event earlier today still
|
// Split into upcoming and past by date, so an event earlier today still
|
||||||
// counts as upcoming. Upcoming earliest first, past most recent first.
|
// counts as upcoming. Upcoming earliest first, past most recent first.
|
||||||
var today = DateOnly.FromDateTime(DateTime.Today);
|
var today = BerlinClock.Today(Time);
|
||||||
_upcomingEvents =
|
_upcomingEvents = [.. UpcomingEvents.From(events, today)];
|
||||||
[
|
|
||||||
.. events
|
|
||||||
.Where(@event => DateOnly.FromDateTime(@event.Start) >= today)
|
|
||||||
.OrderBy(@event => @event.Start)
|
|
||||||
];
|
|
||||||
_pastEvents =
|
_pastEvents =
|
||||||
[
|
[
|
||||||
.. events
|
.. events
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
using Elternbeirat.Contracts;
|
||||||
|
|
||||||
|
namespace Elternbeirat.Web.Features.Events;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Picks the events that still lie ahead.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The events page, the events embed and the "next event" card on the home page
|
||||||
|
/// all need the same answer to "what is coming up?". Keeping the rule here means a
|
||||||
|
/// change to it (e.g. hiding an event once it has ended) reaches all three.
|
||||||
|
/// </remarks>
|
||||||
|
/// <seealso cref="Shared.BerlinClock"/>
|
||||||
|
public static class UpcomingEvents
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the events on or after <paramref name="today"/>, earliest first.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="events">All events, in any order.</param>
|
||||||
|
/// <param name="today">The current day, from <see cref="Shared.BerlinClock.Today"/>.</param>
|
||||||
|
/// <returns>The upcoming events, ordered by start.</returns>
|
||||||
|
/// <remarks>
|
||||||
|
/// Compared by day, not by the minute: an event earlier today still counts as
|
||||||
|
/// upcoming, so a visitor who checks during the evening's meeting still finds it.
|
||||||
|
/// </remarks>
|
||||||
|
public static IEnumerable<Event> From(IEnumerable<Event> events, DateOnly today) =>
|
||||||
|
events
|
||||||
|
.Where(@event => DateOnly.FromDateTime(@event.Start) >= today)
|
||||||
|
.OrderBy(@event => @event.Start);
|
||||||
|
}
|
||||||
@@ -1,12 +1,79 @@
|
|||||||
@page "/"
|
@page "/"
|
||||||
|
@using Elternbeirat.Web.Features.Events
|
||||||
|
|
||||||
<PageTitle>Elternbeirat der IGMH</PageTitle>
|
<PageTitle>Elternbeirat der IGMH</PageTitle>
|
||||||
|
|
||||||
<h1>Elternbeirat der IGMH</h1>
|
@* The hero spans the full window width (see Home.razor.css). Its text is the "home"
|
||||||
|
page's body, taken apart by HomeIntro; the card beside it shows the next event. *@
|
||||||
|
<section class="hero">
|
||||||
|
<div class="hero-inner">
|
||||||
|
<div class="hero-text">
|
||||||
|
<h1>@_intro.Title</h1>
|
||||||
|
|
||||||
<p class="intro">
|
@if (_intro.IntroHtml.Length > 0)
|
||||||
Willkommen beim Elternbeirat der IGMH. Hier finden Sie aktuelle Informationen,
|
{
|
||||||
Antworten auf häufige Fragen und Unterlagen rund um die Elternarbeit.
|
<div class="hero-intro">@((MarkupString)_intro.IntroHtml)</div>
|
||||||
</p>
|
}
|
||||||
|
|
||||||
<PageEmbeds Embed="_embed" />
|
@if (_intro.Links.Count > 0)
|
||||||
|
{
|
||||||
|
<p class="hero-actions">
|
||||||
|
@for (var i = 0; i < _intro.Links.Count; i++)
|
||||||
|
{
|
||||||
|
var link = _intro.Links[i];
|
||||||
|
<a class="btn @(i == 0 ? "btn-primary" : "btn-secondary")" href="@link.Href">@link.Text</a>
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (_nextEvent is not null)
|
||||||
|
{
|
||||||
|
@* The calendar sheet repeats the date as decoration, so it is hidden from
|
||||||
|
screen readers; <EventDate> carries the full date for everyone. *@
|
||||||
|
<a class="next-event" href="/events">
|
||||||
|
<span class="next-event-label">Nächster Termin</span>
|
||||||
|
<span class="next-event-row">
|
||||||
|
<span class="calendar-sheet" aria-hidden="true">
|
||||||
|
<span class="calendar-month">@NextEventMonth</span>
|
||||||
|
<span class="calendar-day">@NextEventDay</span>
|
||||||
|
</span>
|
||||||
|
<span class="next-event-details">
|
||||||
|
<span class="next-event-title">@_nextEvent.Title</span>
|
||||||
|
<EventDate Value="_nextEvent" />
|
||||||
|
@if (!string.IsNullOrWhiteSpace(_nextEvent.Location))
|
||||||
|
{
|
||||||
|
<span class="next-event-location"><Icon Name="map-pin" Size="16" /> @_nextEvent.Location</span>
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
@if (_tiles.Count > 0)
|
||||||
|
{
|
||||||
|
<section class="home-tiles" aria-labelledby="home-tiles-title">
|
||||||
|
<h2 id="home-tiles-title">Bereiche im Überblick</h2>
|
||||||
|
<ul>
|
||||||
|
@foreach (var tile in _tiles)
|
||||||
|
{
|
||||||
|
<li>
|
||||||
|
<a class="tile-link" href="@tile.Href">
|
||||||
|
<span class="tile-icon"><Icon Name="@tile.Icon" Size="24" /></span>
|
||||||
|
<span class="tile-title">@tile.Title</span>
|
||||||
|
@if (tile.Description.Length > 0)
|
||||||
|
{
|
||||||
|
<span class="tile-text">@tile.Description</span>
|
||||||
|
}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="home-embeds">
|
||||||
|
<PageEmbeds Embed="_embed" />
|
||||||
|
</div>
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
using Elternbeirat.Contracts;
|
using Elternbeirat.Contracts;
|
||||||
using Elternbeirat.PocketBase;
|
using Elternbeirat.PocketBase;
|
||||||
|
using Elternbeirat.Web.Features.Events;
|
||||||
|
using Elternbeirat.Web.Shared;
|
||||||
using Microsoft.AspNetCore.Components;
|
using Microsoft.AspNetCore.Components;
|
||||||
|
|
||||||
namespace Elternbeirat.Web.Features.Home;
|
namespace Elternbeirat.Web.Features.Home;
|
||||||
@@ -8,8 +10,15 @@ public partial class Home
|
|||||||
{
|
{
|
||||||
[LoggerMessage(
|
[LoggerMessage(
|
||||||
Level = LogLevel.Warning,
|
Level = LogLevel.Warning,
|
||||||
Message = "Could not load the home page from PocketBase; embeds are omitted.")]
|
Message = "Could not load the pages for the home page from PocketBase; showing the static title only.")]
|
||||||
private static partial void LogSourceUnavailable(
|
private static partial void LogPagesUnavailable(
|
||||||
|
ILogger logger,
|
||||||
|
Exception exception);
|
||||||
|
|
||||||
|
[LoggerMessage(
|
||||||
|
Level = LogLevel.Warning,
|
||||||
|
Message = "Could not load events for the home page from PocketBase; the next-event card is omitted.")]
|
||||||
|
private static partial void LogEventsUnavailable(
|
||||||
ILogger logger,
|
ILogger logger,
|
||||||
Exception exception);
|
Exception exception);
|
||||||
|
|
||||||
@@ -19,26 +28,67 @@ public partial class Home
|
|||||||
[Inject]
|
[Inject]
|
||||||
private ILogger<Home> Logger { get; set; } = null!;
|
private ILogger<Home> Logger { get; set; } = null!;
|
||||||
|
|
||||||
|
[Inject]
|
||||||
|
private TimeProvider Time { get; set; } = null!;
|
||||||
|
|
||||||
[CascadingParameter]
|
[CascadingParameter]
|
||||||
private HttpContext? HttpContext { get; set; }
|
private HttpContext? HttpContext { get; set; }
|
||||||
|
|
||||||
// Which dynamic blocks the home page shows is editorial: it comes from the
|
// Heading, intro and buttons come from the "home" page's body. Until it is loaded
|
||||||
// "home" page's embed field in PocketBase, not from hard-coded markup.
|
// -- and for good if PocketBase is down -- the static fallback heading stands, so
|
||||||
private IReadOnlyList<string> _embed = [];
|
// the start page always has a title.
|
||||||
|
private HomeIntro _intro = HomeIntro.Fallback;
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
// The hero, the tiles and the next-event card are fixed structure of the start
|
||||||
|
// page; which dynamic blocks follow below is editorial and comes from the "home"
|
||||||
|
// page's embed field.
|
||||||
|
private IReadOnlyList<HomeTile> _tiles = [];
|
||||||
|
private IReadOnlyList<string> _embed = [];
|
||||||
|
private Event? _nextEvent;
|
||||||
|
|
||||||
|
// Month and day for the calendar sheet. The sheet is decoration (the full date is
|
||||||
|
// in <EventDate> next to it), so the short month drops its trailing period.
|
||||||
|
private string NextEventMonth =>
|
||||||
|
_nextEvent?.Start.ToString("MMM", Cultures.German).TrimEnd('.') ?? "";
|
||||||
|
|
||||||
|
private string NextEventDay =>
|
||||||
|
_nextEvent?.Start.Day.ToString(Cultures.German) ?? "";
|
||||||
|
|
||||||
|
protected override Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
var token = HttpContext?.RequestAborted ?? CancellationToken.None;
|
||||||
|
return Task.WhenAll(LoadPagesAsync(token), LoadNextEventAsync(token));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pages and events load side by side and fail on their own: without events the
|
||||||
|
// page loses only the card, without pages only text and tiles. Either way the
|
||||||
|
// start page stays a 200 rather than a 503 -- it is degraded, not broken.
|
||||||
|
private async Task LoadPagesAsync(CancellationToken token)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var pages = await PocketBase.GetPagesAsync(HttpContext?.RequestAborted ?? default);
|
var pages = await PocketBase.GetPagesAsync(token);
|
||||||
var home = pages.FirstOrDefault(page => Page.IsHomeSlug(page.Slug));
|
var home = pages.FirstOrDefault(page => Page.IsHomeSlug(page.Slug));
|
||||||
|
_intro = HomeIntro.Parse(home?.Body);
|
||||||
_embed = home?.Embed ?? [];
|
_embed = home?.Embed ?? [];
|
||||||
|
_tiles = HomeTile.From(pages);
|
||||||
}
|
}
|
||||||
catch (PocketBaseUnavailableException exception)
|
catch (PocketBaseUnavailableException exception)
|
||||||
{
|
{
|
||||||
// Only the embeds fail: render the static intro without them instead of a
|
LogPagesUnavailable(Logger, exception);
|
||||||
// 500. The home page's own text is markup, so it stays intact.
|
}
|
||||||
LogSourceUnavailable(Logger, exception);
|
}
|
||||||
|
|
||||||
|
private async Task LoadNextEventAsync(CancellationToken token)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var events = await PocketBase.GetEventsAsync(token);
|
||||||
|
_nextEvent = UpcomingEvents.From(events, BerlinClock.Today(Time)).FirstOrDefault();
|
||||||
|
}
|
||||||
|
catch (PocketBaseUnavailableException exception)
|
||||||
|
{
|
||||||
|
LogEventsUnavailable(Logger, exception);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,322 @@
|
|||||||
.intro {
|
/* --- Hero ---------------------------------------------------------------------
|
||||||
|
A full-width band in the brand gradient with a faint dot pattern on top. The
|
||||||
|
page content sits in a centred column (.content in MainLayout), so the band
|
||||||
|
breaks out of it: 100vw wide, pulled left by the column's offset. body clips the
|
||||||
|
few pixels of scrollbar this adds (see app.css). The negative top margin cancels
|
||||||
|
the column's top padding, so the band meets the header without a strip of page
|
||||||
|
background in between.
|
||||||
|
The pattern is an inline SVG in a data: URL -- no request, nothing from another
|
||||||
|
host. White dots at 12% opacity: visible as texture, too faint to cost the
|
||||||
|
white text any contrast. */
|
||||||
|
.hero {
|
||||||
|
width: 100vw;
|
||||||
|
margin: calc(-1 * var(--space-4)) 0 var(--space-5) calc(50% - 50vw);
|
||||||
|
background:
|
||||||
|
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24'%3E%3Ccircle cx='3' cy='3' r='1.5' fill='%23fff' fill-opacity='.12'/%3E%3C/svg%3E"),
|
||||||
|
var(--gradient-brand);
|
||||||
|
color: var(--color-on-header);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The inside follows the page frame again, so the hero text lines up with the
|
||||||
|
tiles and lists below. Stacked on a phone; from tablet width the next-event card
|
||||||
|
moves beside the text. */
|
||||||
|
.hero-inner {
|
||||||
|
box-sizing: border-box;
|
||||||
|
max-width: var(--content-width);
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: var(--space-6) 1.25rem;
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-5);
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 48rem) {
|
||||||
|
.hero-inner {
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(16rem, 22rem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* On the gradient everything is white: the global heading colour is a blue that
|
||||||
|
would vanish here. */
|
||||||
|
/* balance evens out the lines, so a short last word ("IGMH") never wraps alone. */
|
||||||
|
.hero h1 {
|
||||||
|
color: var(--color-on-header);
|
||||||
|
text-wrap: balance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The intro is editor Markdown, rendered without scope attributes, so it is reached
|
||||||
|
through ::deep. Slightly larger than body text, as a lead. Links stay white and
|
||||||
|
underlined: the blue link colour would be unreadable on the gradient. */
|
||||||
|
.hero-intro ::deep p {
|
||||||
|
font-size: 1.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-intro ::deep a,
|
||||||
|
.hero-intro ::deep a:hover {
|
||||||
|
color: var(--color-on-header);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-3);
|
||||||
|
margin: var(--space-4) 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The hero's buttons, adapted to the dark gradient: the primary turns light (a
|
||||||
|
filled blue would melt into the blue end), the secondary becomes a white outline
|
||||||
|
with the same translucent wash as the header menu on hover. The focus ring turns
|
||||||
|
white for the same reason. */
|
||||||
|
.hero .btn-primary {
|
||||||
|
background: var(--color-button-light);
|
||||||
|
border-color: var(--color-button-light);
|
||||||
|
color: var(--color-on-button-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero .btn-primary:hover {
|
||||||
|
background: var(--color-button-light-hover);
|
||||||
|
border-color: var(--color-button-light-hover);
|
||||||
|
color: var(--color-on-button-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero .btn-secondary {
|
||||||
|
border-color: var(--color-on-header);
|
||||||
|
color: var(--color-on-header);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero .btn-secondary:hover {
|
||||||
|
background: var(--color-nav-hover);
|
||||||
|
color: var(--color-on-header);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero .btn:focus-visible {
|
||||||
|
outline-color: var(--color-on-header);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Next event ---------------------------------------------------------------
|
||||||
|
A card on the gradient: the regular card surface, so its text uses the normal
|
||||||
|
tokens and adapts to dark mode by itself. The whole card is one link to the
|
||||||
|
events page. */
|
||||||
|
.next-event {
|
||||||
|
display: block;
|
||||||
|
padding: var(--space-4);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--color-bg);
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
color: var(--color-text);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: box-shadow var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.next-event:hover {
|
||||||
|
color: var(--color-text);
|
||||||
|
box-shadow: var(--shadow-hover), var(--shadow-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The ring sits outside the card, on the gradient, so it is white like the
|
||||||
|
hero's button rings. */
|
||||||
|
.next-event:focus-visible {
|
||||||
|
outline: 3px solid var(--color-on-header);
|
||||||
|
outline-offset: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.next-event-label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: var(--space-3);
|
||||||
|
color: var(--color-text-soft);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.next-event-row {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-3);
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The calendar sheet: a blue band with the month over a large day number. The band
|
||||||
|
uses the button blue, which keeps white text at >= 5.5:1 in both schemes (the
|
||||||
|
green accent would fall below AA for text this small). */
|
||||||
|
.calendar-sheet {
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
width: 4rem;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--color-bg);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-month {
|
||||||
|
padding: 0.1rem 0;
|
||||||
|
background: var(--color-button);
|
||||||
|
color: var(--color-on-button);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-day {
|
||||||
|
padding: var(--space-1) 0;
|
||||||
|
color: var(--color-heading);
|
||||||
|
font-size: 1.75rem;
|
||||||
|
font-weight: var(--font-weight-heading);
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.next-event-details {
|
||||||
|
min-width: 0; /* lets a long title wrap instead of widening the card */
|
||||||
|
}
|
||||||
|
|
||||||
|
.next-event-title {
|
||||||
|
display: block;
|
||||||
|
color: var(--color-brand);
|
||||||
|
font-size: 1.15rem;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.next-event-location {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-1);
|
||||||
|
margin-top: var(--space-1);
|
||||||
|
color: var(--color-text-soft);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Tiles --------------------------------------------------------------------
|
||||||
|
One card per header page. Fixed columns instead of auto-fill: two on a phone,
|
||||||
|
three from tablet width, so the six pages always fill whole rows and never leave
|
||||||
|
a half row behind. On a phone the tiles are compact (icon and title only), the
|
||||||
|
teaser would make six tiles a long scroll. Hover = stronger shadow and a blue
|
||||||
|
edge, nothing moves. */
|
||||||
|
.home-tiles {
|
||||||
|
margin-block: var(--space-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-tiles ul {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: var(--space-3);
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* On a phone the icon sits above the title: beside it, a half-width column leaves
|
||||||
|
too little room and titles get cut. */
|
||||||
|
.tile-link {
|
||||||
|
box-sizing: border-box;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
height: 100%;
|
||||||
|
text-align: center;
|
||||||
|
padding: var(--space-3);
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--color-bg);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
color: var(--color-text);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: box-shadow var(--transition), border-color var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tile-link:hover {
|
||||||
|
border-color: var(--color-brand);
|
||||||
|
box-shadow: var(--shadow-hover);
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tile-link:focus-visible {
|
||||||
|
outline: 3px solid var(--color-focus);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The icon in a soft round tint. */
|
||||||
|
.tile-icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 3rem;
|
||||||
|
height: 3rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--color-brand-tint);
|
||||||
|
color: var(--color-brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Long German compounds must wrap rather than overflow the narrow tile. */
|
||||||
|
.tile-title {
|
||||||
|
max-width: 100%;
|
||||||
|
color: var(--color-brand);
|
||||||
font-size: 1.1rem;
|
font-size: 1.1rem;
|
||||||
/* Softer than body text, via the token -- so it adapts in dark mode instead of
|
font-weight: 700;
|
||||||
staying a fixed dark grey that turns unreadable there. */
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hidden on a phone; the title alone says where the tile leads. */
|
||||||
|
.tile-text {
|
||||||
|
display: none;
|
||||||
color: var(--color-text-soft);
|
color: var(--color-text-soft);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 48rem) {
|
||||||
|
.home-tiles ul {
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Icon left, title and teaser right. Both start at the top, so the icon stays
|
||||||
|
beside the title even when the teaser runs over several lines. The icon spans
|
||||||
|
both rows here, where the teaser is visible. */
|
||||||
|
.tile-link {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto minmax(0, 1fr);
|
||||||
|
column-gap: var(--space-3);
|
||||||
|
row-gap: 0;
|
||||||
|
align-content: start;
|
||||||
|
padding: var(--space-3) var(--space-4);
|
||||||
|
text-align: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tile-icon {
|
||||||
|
grid-row: span 2;
|
||||||
|
align-self: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The teaser is a whole sentence; the tile cuts it visually after three lines,
|
||||||
|
so the tiles of a row keep a similar height whatever the editor wrote. The
|
||||||
|
-webkit- box is the form every current browser supports for line-clamp. */
|
||||||
|
.tile-text {
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
-webkit-line-clamp: 3;
|
||||||
|
line-clamp: 3;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Embeds -------------------------------------------------------------------
|
||||||
|
The blocks the home page opts into (posts, events) sit side by side from desktop
|
||||||
|
width, so the start page shows both without a long scroll. One column below.
|
||||||
|
In the grid the blocks' own top margin no longer collapses with the margin of
|
||||||
|
the tiles above, so it is dropped there; otherwise the gap would be doubled. */
|
||||||
|
@media (min-width: 64rem) {
|
||||||
|
.home-embeds {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: var(--space-5);
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-embeds ::deep .embed {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
using Elternbeirat.Web.Shared;
|
||||||
|
using Markdig.Syntax;
|
||||||
|
using Markdig.Syntax.Inlines;
|
||||||
|
|
||||||
|
namespace Elternbeirat.Web.Features.Home;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The head of the home page, taken apart from the <c>home</c> page's Markdown body:
|
||||||
|
/// a large heading, an intro and a row of buttons.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="Title">The large heading.</param>
|
||||||
|
/// <param name="IntroHtml">The intro rendered to HTML, or an empty string.</param>
|
||||||
|
/// <param name="Links">The buttons, in the order the editor wrote them; may be empty.</param>
|
||||||
|
/// <remarks>
|
||||||
|
/// Everything visible here is written by editors in PocketBase. The page only gives
|
||||||
|
/// it the hero layout, so the editor needs no new field and no special syntax:
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item><description>The first <c># heading</c> becomes the large heading.</description></item>
|
||||||
|
/// <item>
|
||||||
|
/// <description>
|
||||||
|
/// A last paragraph made of nothing but links becomes the button row;
|
||||||
|
/// the first link is the primary button.
|
||||||
|
/// </description>
|
||||||
|
/// </item>
|
||||||
|
/// <item><description>Everything else is the intro text.</description></item>
|
||||||
|
/// </list>
|
||||||
|
/// <para>
|
||||||
|
/// <see cref="Fallback"/> keeps a static heading when PocketBase cannot be
|
||||||
|
/// reached, so the start page never renders without a title.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
/// <example>
|
||||||
|
/// <code>
|
||||||
|
/// HomeIntro.Parse("# Willkommen\n\nSchön, dass Sie da sind.\n\n[Termine](/events) [Kontakt](/contact)");
|
||||||
|
/// // Title "Willkommen", IntroHtml "<p>Schön, dass Sie da sind.</p>\n", two links
|
||||||
|
/// </code>
|
||||||
|
/// </example>
|
||||||
|
public sealed record HomeIntro(string Title, string IntroHtml, IReadOnlyList<HomeLink> Links)
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The heading shown when the body has none or PocketBase is not reachable.
|
||||||
|
/// </summary>
|
||||||
|
public const string FallbackTitle = "Elternbeirat der IGMH";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The head of the home page without any content from PocketBase: the static
|
||||||
|
/// heading, no intro, no buttons.
|
||||||
|
/// </summary>
|
||||||
|
public static HomeIntro Fallback { get; } = new(FallbackTitle, "", []);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Takes the <c>home</c> page's body apart into heading, intro and buttons.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="markdown">The body; may be <see langword="null"/> or empty.</param>
|
||||||
|
/// <returns>
|
||||||
|
/// The parts; <see cref="Fallback"/> for an empty body. Without a level-1
|
||||||
|
/// heading, the title is <see cref="FallbackTitle"/> and the rest is used as is.
|
||||||
|
/// </returns>
|
||||||
|
/// <remarks>
|
||||||
|
/// Only top-level blocks are looked at: a heading or a link paragraph inside a
|
||||||
|
/// list or a <c>:::</c> block stays part of the intro. Links in the button row
|
||||||
|
/// may be separated by spaces or line breaks; any other text in the paragraph
|
||||||
|
/// (e.g. "oder") keeps it a normal paragraph, because the button row would drop
|
||||||
|
/// that text.
|
||||||
|
/// </remarks>
|
||||||
|
public static HomeIntro Parse(string? markdown)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(markdown))
|
||||||
|
{
|
||||||
|
return Fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
var document = Markdown.Parse(markdown);
|
||||||
|
|
||||||
|
var heading = document.OfType<HeadingBlock>().FirstOrDefault(block => block.Level == 1);
|
||||||
|
if (heading is not null)
|
||||||
|
{
|
||||||
|
document.Remove(heading);
|
||||||
|
}
|
||||||
|
|
||||||
|
var links = document.LastChild is ParagraphBlock last ? LinksOnly(last) : [];
|
||||||
|
if (links.Count > 0)
|
||||||
|
{
|
||||||
|
document.RemoveAt(document.Count - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
var title = heading?.Inline is { } inline ? Markdown.PlainText(inline) : "";
|
||||||
|
return new HomeIntro(
|
||||||
|
string.IsNullOrEmpty(title) ? FallbackTitle : title,
|
||||||
|
Markdown.Render(document),
|
||||||
|
links);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reads the links of a paragraph that holds nothing but links.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="paragraph">The paragraph to inspect.</param>
|
||||||
|
/// <returns>
|
||||||
|
/// Its links, or an empty list if the paragraph contains anything besides links
|
||||||
|
/// and whitespace (including an image).
|
||||||
|
/// </returns>
|
||||||
|
private static List<HomeLink> LinksOnly(ParagraphBlock paragraph) =>
|
||||||
|
paragraph.Inline?.Where(inline => !Markdown.IsBlank(inline)).ToList() is { Count: > 0 } inlines
|
||||||
|
&& inlines.All(inline => inline is LinkInline { IsImage: false } or AutolinkInline)
|
||||||
|
? [.. inlines.Select(ToLink)]
|
||||||
|
: [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Turns a link inline into a button.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="inline">A <see cref="LinkInline"/> or <see cref="AutolinkInline"/>.</param>
|
||||||
|
/// <returns>The button's target and visible text.</returns>
|
||||||
|
/// <exception cref="ArgumentException"><paramref name="inline"/> is neither kind of link.</exception>
|
||||||
|
private static HomeLink ToLink(Inline inline) =>
|
||||||
|
inline switch
|
||||||
|
{
|
||||||
|
LinkInline link => new HomeLink(link.Url ?? "", Markdown.PlainText(link)),
|
||||||
|
AutolinkInline autolink => new HomeLink(autolink.Url, autolink.Url),
|
||||||
|
_ => throw new ArgumentException("Not a link.", nameof(inline)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A button in the head of the home page.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="Href">The link target as the editor wrote it.</param>
|
||||||
|
/// <param name="Text">The visible button text.</param>
|
||||||
|
public sealed record HomeLink(string Href, string Text);
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
using Elternbeirat.Contracts;
|
||||||
|
using Elternbeirat.Web.Features.Layout;
|
||||||
|
using Elternbeirat.Web.Shared;
|
||||||
|
|
||||||
|
namespace Elternbeirat.Web.Features.Home;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One tile on the home page, leading to a section of the site.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="Href">Target URL of the page.</param>
|
||||||
|
/// <param name="Title">The page's title.</param>
|
||||||
|
/// <param name="Description">
|
||||||
|
/// The first sentence of the page's body, or an empty string, in which case the
|
||||||
|
/// tile shows only the title.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="Icon">The icon name, see <see cref="TileIcons"/>.</param>
|
||||||
|
public sealed record HomeTile(string Href, string Title, string Description, string Icon)
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Builds one tile per header page except the home page itself.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="pages">All public pages.</param>
|
||||||
|
/// <returns>The tiles, in menu order.</returns>
|
||||||
|
/// <remarks>
|
||||||
|
/// The tiles mirror the header menu (<see cref="NavBuilder.HeaderPages"/>), so a
|
||||||
|
/// page an editor puts into the header appears here too, without any code. The
|
||||||
|
/// home page is left out: a tile leading to the page it sits on would be useless.
|
||||||
|
/// </remarks>
|
||||||
|
public static IReadOnlyList<HomeTile> From(IReadOnlyList<Page> pages) =>
|
||||||
|
[
|
||||||
|
.. NavBuilder.HeaderPages(pages)
|
||||||
|
.Where(page => !Page.IsHomeSlug(page.Slug))
|
||||||
|
.Select(page => new HomeTile(
|
||||||
|
NavBuilder.HrefFor(page.Slug),
|
||||||
|
page.Title,
|
||||||
|
Markdown.FirstSentence(page.Body),
|
||||||
|
TileIcons.For(page.Slug))),
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
using System.Collections.Frozen;
|
||||||
|
using Elternbeirat.Web.Shared;
|
||||||
|
|
||||||
|
namespace Elternbeirat.Web.Features.Home;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Chooses the icon of a home page tile from the page's slug.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Pure presentation, so it lives in code: the schema has no icon field, and adding
|
||||||
|
/// one needs a schema change that cannot reach production yet. A page without an
|
||||||
|
/// entry here (e.g. one an editor just created) gets <see cref="Default"/>, so a
|
||||||
|
/// new page always shows up as a complete tile.
|
||||||
|
/// </remarks>
|
||||||
|
/// <seealso cref="IconSet"/>
|
||||||
|
public static class TileIcons
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The icon for a slug without its own entry: a generic page.
|
||||||
|
/// </summary>
|
||||||
|
public const string Default = "file-text";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Icon name per slug. Every value must be a name in <see cref="IconSet"/>.
|
||||||
|
/// </summary>
|
||||||
|
private static readonly FrozenDictionary<string, string> Icons =
|
||||||
|
new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["board"] = "users",
|
||||||
|
["patrons"] = "heart-handshake",
|
||||||
|
["faqs"] = "help-circle",
|
||||||
|
["downloads"] = "download",
|
||||||
|
["posts"] = "newspaper",
|
||||||
|
["events"] = "calendar",
|
||||||
|
["contact"] = "mail",
|
||||||
|
}.ToFrozenDictionary(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the icon name for a page.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="slug">The page's slug, e.g. <c>board</c>.</param>
|
||||||
|
/// <returns>An icon name from <see cref="IconSet"/>; <see cref="Default"/> for an unknown slug.</returns>
|
||||||
|
public static string For(string slug) =>
|
||||||
|
Icons.GetValueOrDefault(slug, Default);
|
||||||
|
}
|
||||||
@@ -11,23 +11,45 @@ public static class NavBuilder
|
|||||||
{
|
{
|
||||||
/// <summary>Header links, ordered, built from pages with location "header".</summary>
|
/// <summary>Header links, ordered, built from pages with location "header".</summary>
|
||||||
public static IReadOnlyList<NavLinkItem> Header(IReadOnlyList<Page> pages) =>
|
public static IReadOnlyList<NavLinkItem> Header(IReadOnlyList<Page> pages) =>
|
||||||
Build(pages, "header");
|
ToLinks(PagesAt(pages, "header"));
|
||||||
|
|
||||||
/// <summary>Footer links, ordered, built from pages with location "footer".</summary>
|
/// <summary>Footer links, ordered, built from pages with location "footer".</summary>
|
||||||
public static IReadOnlyList<NavLinkItem> Footer(IReadOnlyList<Page> pages) =>
|
public static IReadOnlyList<NavLinkItem> Footer(IReadOnlyList<Page> pages) =>
|
||||||
Build(pages, "footer");
|
ToLinks(PagesAt(pages, "footer"));
|
||||||
|
|
||||||
private static List<NavLinkItem> Build(
|
/// <summary>
|
||||||
|
/// Returns the pages of the header menu themselves, in menu order.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="pages">All public pages.</param>
|
||||||
|
/// <returns>The pages with location <c>header</c>, ordered by <see cref="Page.Order"/>.</returns>
|
||||||
|
/// <remarks>
|
||||||
|
/// For callers that need more than a link, such as the tiles on the home page,
|
||||||
|
/// which also show part of each page's body. Using the same selection as
|
||||||
|
/// <see cref="Header"/> keeps tiles and menu in step.
|
||||||
|
/// </remarks>
|
||||||
|
public static IReadOnlyList<Page> HeaderPages(IReadOnlyList<Page> pages) =>
|
||||||
|
PagesAt(pages, "header");
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the URL a page is reached at.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="slug">The page's slug.</param>
|
||||||
|
/// <returns><c>/</c> for the home page, otherwise <c>/{slug}</c>.</returns>
|
||||||
|
/// <remarks>
|
||||||
|
/// The home page owns "/" (served by the Home component); every other slug is
|
||||||
|
/// reached at "/{slug}", where either a literal route or the catch-all content
|
||||||
|
/// page renders it.
|
||||||
|
/// </remarks>
|
||||||
|
public static string HrefFor(string slug) =>
|
||||||
|
Page.IsHomeSlug(slug) ? "/" : $"/{slug}";
|
||||||
|
|
||||||
|
private static List<Page> PagesAt(
|
||||||
IReadOnlyList<Page> pages,
|
IReadOnlyList<Page> pages,
|
||||||
string location) =>
|
string location) =>
|
||||||
[.. pages
|
[.. pages
|
||||||
.Where(page => string.Equals(page.Location, location, StringComparison.Ordinal))
|
.Where(page => string.Equals(page.Location, location, StringComparison.Ordinal))
|
||||||
.OrderBy(page => page.Order)
|
.OrderBy(page => page.Order)];
|
||||||
.Select(page => new NavLinkItem(HrefFor(page.Slug), page.Title))];
|
|
||||||
|
|
||||||
// The home page owns "/" (served by the Home component); every other slug is
|
private static List<NavLinkItem> ToLinks(IEnumerable<Page> pages) =>
|
||||||
// reached at "/{slug}", where either a literal route or the catch-all content
|
[.. pages.Select(page => new NavLinkItem(HrefFor(page.Slug), page.Title))];
|
||||||
// page renders it.
|
|
||||||
private static string HrefFor(string slug) =>
|
|
||||||
Page.IsHomeSlug(slug) ? "/" : $"/{slug}";
|
|
||||||
}
|
}
|
||||||
@@ -8,6 +8,10 @@ var builder = WebApplication.CreateBuilder(args);
|
|||||||
|
|
||||||
builder.Services.AddRazorComponents();
|
builder.Services.AddRazorComponents();
|
||||||
|
|
||||||
|
// "Today" for upcoming events comes from this clock, not DateTime.Today, so the
|
||||||
|
// smoke tests can swap in a fixed date and check the pages before and after an event.
|
||||||
|
builder.Services.AddSingleton(TimeProvider.System);
|
||||||
|
|
||||||
// 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 config: appsettings for local dev, the PocketBase__BaseUrl env var
|
// comes from config: appsettings for local dev, the PocketBase__BaseUrl env var
|
||||||
// on the server (see compose.yaml).
|
// on the server (see compose.yaml).
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
namespace Elternbeirat.Web.Shared;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tells the current calendar day as people at the school see it.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The container's clock runs in UTC, so <see cref="DateTime.Today"/> would flip to
|
||||||
|
/// the next day at 01:00 or 02:00 Berlin time, and an event shortly after midnight
|
||||||
|
/// would count as past for an hour or two. Event times are Berlin wall-clock
|
||||||
|
/// values (see <c>LocalDateTimeConverter</c>), so "today" has to be a Berlin day
|
||||||
|
/// too.
|
||||||
|
/// <para>
|
||||||
|
/// The time comes from an injected <see cref="TimeProvider"/>, not the system
|
||||||
|
/// clock, so tests can pin "today" and check what a page shows before and after
|
||||||
|
/// an event.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public static class BerlinClock
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// IANA id; resolves on every platform .NET supports, Windows included.
|
||||||
|
/// </summary>
|
||||||
|
private static readonly TimeZoneInfo Berlin =
|
||||||
|
TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin");
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns today's date in Berlin.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="time">The clock to read, usually <see cref="TimeProvider.System"/>.</param>
|
||||||
|
/// <returns>The current Berlin calendar day.</returns>
|
||||||
|
public static DateOnly Today(TimeProvider time) =>
|
||||||
|
DateOnly.FromDateTime(TimeZoneInfo.ConvertTime(time.GetUtcNow(), Berlin).DateTime);
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using System.Text;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
using Markdig;
|
using Markdig;
|
||||||
using Markdig.Renderers.Html;
|
using Markdig.Renderers.Html;
|
||||||
using Markdig.Syntax;
|
using Markdig.Syntax;
|
||||||
@@ -45,7 +47,7 @@ namespace Elternbeirat.Web.Shared;
|
|||||||
/// </para>
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
/// <seealso cref="IconSet"/>
|
/// <seealso cref="IconSet"/>
|
||||||
public static class Markdown
|
public static partial class Markdown
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The class marking a paragraph or list item whose only content is one link.
|
/// The class marking a paragraph or list item whose only content is one link.
|
||||||
@@ -75,9 +77,134 @@ public static class Markdown
|
|||||||
/// </code>
|
/// </code>
|
||||||
/// </example>
|
/// </example>
|
||||||
public static string ToHtml(string? markdown) =>
|
public static string ToHtml(string? markdown) =>
|
||||||
string.IsNullOrEmpty(markdown)
|
string.IsNullOrEmpty(markdown) ? "" : Render(Parse(markdown));
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Extracts the first sentence of a Markdown text as plain text, e.g. as the
|
||||||
|
/// short description on a home page tile.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="markdown">The editor's Markdown; may be <see langword="null"/>.</param>
|
||||||
|
/// <returns>
|
||||||
|
/// The first sentence of the first top-level paragraph, with all Markdown
|
||||||
|
/// removed, whitespace collapsed and a trailing colon dropped, or an empty
|
||||||
|
/// string when there is no such paragraph (empty body, or only headings, lists
|
||||||
|
/// and blocks).
|
||||||
|
/// </returns>
|
||||||
|
/// <remarks>
|
||||||
|
/// Only top-level paragraphs count: a heading repeats the title, and the text in
|
||||||
|
/// a list or a <c>:::</c> block is rarely a sentence that describes the page.
|
||||||
|
/// <para>
|
||||||
|
/// A sentence ends at <c>.</c>, <c>!</c> or <c>?</c> followed by a space or
|
||||||
|
/// the end. A period after a single letter or a number does not count, so
|
||||||
|
/// <c>z. B.</c> and <c>13. November</c> do not cut the sentence short. A
|
||||||
|
/// longer abbreviation such as <c>bzw.</c> still does; that costs the rest
|
||||||
|
/// of a teaser, never the page.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// A paragraph that leads into a list often ends in <c>:</c> without a
|
||||||
|
/// period; as a teaser the colon would point at a list that is not there.
|
||||||
|
/// The sentence is returned whole: a long one is cut by the tile's CSS,
|
||||||
|
/// which knows the space it has, not by a character count.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
/// <example>
|
||||||
|
/// <code>
|
||||||
|
/// Markdown.FirstSentence("# Vorstand\n\nWir sind **sieben** Eltern. Mehr unten.");
|
||||||
|
/// // "Wir sind sieben Eltern."
|
||||||
|
/// </code>
|
||||||
|
/// </example>
|
||||||
|
public static string FirstSentence(string? markdown) =>
|
||||||
|
string.IsNullOrWhiteSpace(markdown)
|
||||||
? ""
|
? ""
|
||||||
: Markdig.Markdown.ToHtml(MarkLoneLinks(Markdig.Markdown.Parse(markdown, Pipeline)), Pipeline);
|
: Parse(markdown).OfType<ParagraphBlock>().FirstOrDefault()?.Inline is { } inline
|
||||||
|
? UpToSentenceEnd(PlainText(inline)).TrimEnd(':', ' ')
|
||||||
|
: "";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parses Markdown with the site's pipeline, for callers that take the document
|
||||||
|
/// apart before rendering it (see <see cref="Render"/>).
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="markdown">The editor's Markdown.</param>
|
||||||
|
/// <returns>The parsed document.</returns>
|
||||||
|
internal static MarkdownDocument Parse(string markdown) =>
|
||||||
|
Markdig.Markdown.Parse(markdown, Pipeline);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Renders a document from <see cref="Parse"/> to HTML, marking lone links on
|
||||||
|
/// the way, exactly as <see cref="ToHtml"/> does.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="document">The parsed document; changed in place.</param>
|
||||||
|
/// <returns>The rendered HTML.</returns>
|
||||||
|
internal static string Render(MarkdownDocument document) =>
|
||||||
|
Markdig.Markdown.ToHtml(MarkLoneLinks(document), Pipeline);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Flattens inline Markdown to the text a reader sees: emphasis and link
|
||||||
|
/// markup dropped, link text kept, images left out, whitespace collapsed.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="inline">The inline content, e.g. of a paragraph or heading.</param>
|
||||||
|
/// <returns>The visible text, trimmed.</returns>
|
||||||
|
internal static string PlainText(ContainerInline inline) =>
|
||||||
|
Whitespace().Replace(AppendText(new StringBuilder(), inline).ToString(), " ").Trim();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether an inline carries no visible content (whitespace or a line break).
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="inline">The inline to inspect.</param>
|
||||||
|
/// <returns>
|
||||||
|
/// <see langword="true"/> if it can be ignored when looking for links that stand
|
||||||
|
/// alone in a paragraph.
|
||||||
|
/// </returns>
|
||||||
|
internal static bool IsBlank(Inline inline) =>
|
||||||
|
inline switch
|
||||||
|
{
|
||||||
|
LineBreakInline => true,
|
||||||
|
LiteralInline literal => literal.Content.IsEmptyOrWhitespace(),
|
||||||
|
_ => false,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Appends the visible text of an inline and its children.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="text">The builder to append to.</param>
|
||||||
|
/// <param name="inline">The inline to flatten.</param>
|
||||||
|
/// <returns>The same <paramref name="text"/>, for chaining.</returns>
|
||||||
|
private static StringBuilder AppendText(StringBuilder text, Inline inline) =>
|
||||||
|
inline switch
|
||||||
|
{
|
||||||
|
LiteralInline literal => text.Append(literal.Content.ToString()),
|
||||||
|
CodeInline code => text.Append(code.Content),
|
||||||
|
HtmlEntityInline entity => text.Append(entity.Transcoded.ToString()),
|
||||||
|
AutolinkInline autolink => text.Append(autolink.Url),
|
||||||
|
LineBreakInline => text.Append(' '),
|
||||||
|
LinkInline { IsImage: true } => text,
|
||||||
|
ContainerInline container => container.Aggregate(text, AppendText),
|
||||||
|
_ => text,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Cuts a text after its first sentence.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="text">Plain text.</param>
|
||||||
|
/// <returns>
|
||||||
|
/// The first sentence including its end mark, or the whole text if it has none.
|
||||||
|
/// </returns>
|
||||||
|
private static string UpToSentenceEnd(string text) =>
|
||||||
|
SentenceEnd().Match(text) is { Success: true } end ? text[..(end.Index + 1)] : text;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Matches the end mark of a sentence: <c>.</c>, <c>!</c> or <c>?</c> before a
|
||||||
|
/// space or the end, unless it follows a lone letter (<c>z.</c>) or a number
|
||||||
|
/// (<c>13.</c>).
|
||||||
|
/// </summary>
|
||||||
|
[GeneratedRegex(@"(?<!\b\p{L}|\b\d+)[.!?](?=\s|$)")]
|
||||||
|
private static partial Regex SentenceEnd();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Matches a run of whitespace, collapsed to one space in plain text.
|
||||||
|
/// </summary>
|
||||||
|
[GeneratedRegex(@"\s+")]
|
||||||
|
private static partial Regex Whitespace();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Adds <see cref="LoneLinkClass"/> to every paragraph that holds nothing but
|
/// Adds <see cref="LoneLinkClass"/> to every paragraph that holds nothing but
|
||||||
@@ -114,17 +241,4 @@ public static class Markdown
|
|||||||
paragraph.Inline?
|
paragraph.Inline?
|
||||||
.Where(inline => !IsBlank(inline))
|
.Where(inline => !IsBlank(inline))
|
||||||
.ToList() is [LinkInline { IsImage: false } or AutolinkInline];
|
.ToList() is [LinkInline { IsImage: false } or AutolinkInline];
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Whether an inline carries no visible content (whitespace or a line break).
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="inline">The inline to inspect.</param>
|
|
||||||
/// <returns><see langword="true"/> if it can be ignored when looking for a lone link.</returns>
|
|
||||||
private static bool IsBlank(Inline inline) =>
|
|
||||||
inline switch
|
|
||||||
{
|
|
||||||
LineBreakInline => true,
|
|
||||||
LiteralInline literal => literal.Content.IsEmptyOrWhitespace(),
|
|
||||||
_ => false,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
@@ -62,6 +62,18 @@
|
|||||||
--color-button-hover: #1f5477;
|
--color-button-hover: #1f5477;
|
||||||
--color-on-button: #fff;
|
--color-on-button: #fff;
|
||||||
|
|
||||||
|
/* The light button for dark surfaces (the brand gradient of the home hero),
|
||||||
|
where a filled blue button would melt into the blue end. Dark blue text on
|
||||||
|
white, 8:1; the hover is a faint blue tint, so the button still reads as one. */
|
||||||
|
--color-button-light: #fff;
|
||||||
|
--color-button-light-hover: #eaf3f9;
|
||||||
|
--color-on-button-light: #1f5477;
|
||||||
|
|
||||||
|
/* Keyboard focus ring (:focus-visible). Its own token so it can stay strong on
|
||||||
|
the light page (dark blue, 8:1 on white) and flip to a light blue on the dark
|
||||||
|
one; on the gradient the hero switches it to white. */
|
||||||
|
--color-focus: #1f5477;
|
||||||
|
|
||||||
/* Headings. Its own token (not --color-brand) so headings and links can differ:
|
/* Headings. Its own token (not --color-brand) so headings and links can differ:
|
||||||
in dark mode headings become a soft white while links keep the brand blue. */
|
in dark mode headings become a soft white while links keep the brand blue. */
|
||||||
--color-heading: #2b6f9c;
|
--color-heading: #2b6f9c;
|
||||||
@@ -158,6 +170,13 @@
|
|||||||
text, calm on the dark page */
|
text, calm on the dark page */
|
||||||
--color-button-hover: #1f5477;
|
--color-button-hover: #1f5477;
|
||||||
|
|
||||||
|
--color-button-light: #e8eef3; /* white eased down, so it does not glare
|
||||||
|
on the darker gradient */
|
||||||
|
--color-button-light-hover: #d3dee6;
|
||||||
|
--color-on-button-light: #1a3d57; /* 9:1 on the eased white */
|
||||||
|
|
||||||
|
--color-focus: #a3d5f4; /* light blue: a dark ring would vanish here */
|
||||||
|
|
||||||
--color-heading: #c9ced6; /* soft white, kept a step brighter than the
|
--color-heading: #c9ced6; /* soft white, kept a step brighter than the
|
||||||
body text so headings still lead the page */
|
body text so headings still lead the page */
|
||||||
|
|
||||||
@@ -211,6 +230,11 @@ body {
|
|||||||
background: var(--color-page);
|
background: var(--color-page);
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
font-size: var(--font-size-body);
|
font-size: var(--font-size-body);
|
||||||
|
/* Full-width bands inside the content column (the home hero) reach the window
|
||||||
|
edges with 100vw, which includes the vertical scrollbar and would add a few
|
||||||
|
pixels of sideways scroll. clip, not hidden: hidden would make body a scroll
|
||||||
|
container and stop the sticky header from sticking. */
|
||||||
|
overflow-x: clip;
|
||||||
}
|
}
|
||||||
|
|
||||||
a {
|
a {
|
||||||
@@ -273,6 +297,54 @@ h1:focus {
|
|||||||
vertical-align: -0.15em;
|
vertical-align: -0.15em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Buttons for links that are an action (the hero's "Kommende Termine", "Kontakt
|
||||||
|
aufnehmen"). Global, because the look must be the same wherever a component puts
|
||||||
|
one. Primary = filled, secondary = outlined; both carry the same 2px border, so a
|
||||||
|
primary and a secondary side by side are exactly the same height. Hover changes
|
||||||
|
colour and shadow only -- nothing moves (hover movement was removed on purpose). */
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
padding: var(--space-2) var(--space-4);
|
||||||
|
border: 2px solid transparent;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.4;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: background-color var(--transition), box-shadow var(--transition),
|
||||||
|
color var(--transition), border-color var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:focus-visible {
|
||||||
|
outline: 3px solid var(--color-focus);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: var(--color-button);
|
||||||
|
border-color: var(--color-button);
|
||||||
|
color: var(--color-on-button);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
background: var(--color-button-hover);
|
||||||
|
border-color: var(--color-button-hover);
|
||||||
|
color: var(--color-on-button);
|
||||||
|
box-shadow: var(--shadow-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background: transparent;
|
||||||
|
border-color: var(--color-button);
|
||||||
|
color: var(--color-brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover {
|
||||||
|
background: var(--color-brand-tint);
|
||||||
|
color: var(--color-brand);
|
||||||
|
}
|
||||||
|
|
||||||
/* Header navigation links. These live here (global), not in MainLayout.razor.css:
|
/* Header navigation links. These live here (global), not in MainLayout.razor.css:
|
||||||
NavLink renders plain <a> elements without a scoped-CSS attribute, so a scoped
|
NavLink renders plain <a> elements without a scoped-CSS attribute, so a scoped
|
||||||
".main-nav a" rule would not match them -- the links would fall back to the
|
".main-nav a" rule would not match them -- the links would fall back to the
|
||||||
|
|||||||
@@ -105,6 +105,41 @@ sofort live, ohne Code und ohne Rebuild:
|
|||||||
`embed` = reine Textseite. Die FAQ-Liste ist kein `embed`: `/faqs` ist eine
|
`embed` = reine Textseite. Die FAQ-Liste ist kein `embed`: `/faqs` ist eine
|
||||||
feste Ansicht (siehe *Häufige Fragen*).
|
feste Ansicht (siehe *Häufige Fragen*).
|
||||||
|
|
||||||
|
### Die Startseite (`home`)
|
||||||
|
|
||||||
|
Der `body` der Startseite füllt den farbigen Kopfbereich. Die App zerlegt ihn so:
|
||||||
|
|
||||||
|
- Die **erste `#`-Überschrift** wird die große Überschrift im Kopfbereich. Fehlt
|
||||||
|
sie, steht dort „Elternbeirat der IGMH".
|
||||||
|
- Besteht der **letzte Absatz nur aus Links** (je Link eine Zeile), werden daraus
|
||||||
|
Knöpfe — der erste hervorgehoben, die übrigen umrandet. Steht in dem Absatz
|
||||||
|
noch anderer Text, bleibt er normaler Text.
|
||||||
|
- Alles andere ist der Einleitungstext daneben.
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# Willkommen beim Elternbeirat der IGMH
|
||||||
|
|
||||||
|
Hier finden Sie aktuelle Informationen rund um die Elternarbeit.
|
||||||
|
|
||||||
|
[Kommende Termine](/events)
|
||||||
|
[Kontakt aufnehmen](/contact)
|
||||||
|
```
|
||||||
|
|
||||||
|
Darunter erscheinen automatisch:
|
||||||
|
|
||||||
|
- **Nächster Termin** — der früheste kommende Termin aus `events`; gibt es
|
||||||
|
keinen, entfällt die Karte.
|
||||||
|
- **Kacheln** — eine pro öffentlicher Seite mit `location = header` (außer der
|
||||||
|
Startseite), in Menü-Reihenfolge. Der Kacheltext ist der **erste Satz** des
|
||||||
|
`body` (ohne Überschrift, ein Doppelpunkt am Ende fällt weg; ein langer Satz
|
||||||
|
wird nach drei Zeilen mit „…“ abgeschnitten); beginnt der Text mit einer Liste oder
|
||||||
|
einem Baustein, zeigt die Kachel nur den Titel. Auf dem Handy zeigen die
|
||||||
|
Kacheln immer nur Symbol und Titel (zwei Spalten), ab Tablet-Breite auch den
|
||||||
|
Text (drei Spalten). Das Symbol hängt am Slug und ist
|
||||||
|
im Code festgelegt; eine neue Seite bekommt ein Standardsymbol.
|
||||||
|
|
||||||
|
Eigene Felder für Kacheltext und Symbol gibt es (noch) nicht.
|
||||||
|
|
||||||
> **Impressum und Datenschutz** hängen an ihren Slugs (`imprint`, `privacy`).
|
> **Impressum und Datenschutz** hängen an ihren Slugs (`imprint`, `privacy`).
|
||||||
> Diese Slugs nicht ändern — sonst laufen die rechtlich verlinkten Adressen ins
|
> Diese Slugs nicht ändern — sonst laufen die rechtlich verlinkten Adressen ins
|
||||||
> Leere (404).
|
> Leere (404).
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ 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":"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.\n\n::: kennzahlen\n- **seit 1975** im Einsatz für die IGMH\n- **über 500** Mitglieder\n- **ab 10 €** Jahresbeitrag\n:::\n\n::: hinweis\nDer Förderverein ist ein eigenständiger, gemeinnützig anerkannter Verein und nicht Teil des Elternbeirats.\n:::\n\n## Was der Verein fördert\n\n::: kacheln\n- **Pädagogik** Bessere Rahmenbedingungen für die Arbeit an der Schule\n- **Projekte** Musische, sportliche und kulturelle Vorhaben\n- **Ausstattung** Anschaffungen, die allen Schülerinnen und Schülern zugutekommen\n- **Chancengerechtigkeit** Damit kein Kind aus finanziellen Gründen zurückbleibt\n:::\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\n::: aufruf\nDie Beitrittserklärung füllen Sie online beim Förderverein aus.\n\n[Zur Beitrittserklärung](https://easyverein.com/public/FDI/applicationform/21612)\n:::\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":"Formulare und Dokumente zum Herunterladen:\n\n- [Aufnahmeantrag Förderverein](/dokumente/aufnahmeantrag-foerderverein.pdf)\n- [Einzugsermächtigung](/dokumente/einzugsermaechtigung.pdf)\n- [Erstattungsformular Klassenkasse](/dokumente/erstattungsformular-klassenkasse.pdf)\n\n::: hinweis\nFehlt ein Dokument? Melden Sie sich über die [Kontaktseite](/contact).\n:::","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\n[kontakt@elternbeirat-igmh.info](mailto:kontakt@elternbeirat-igmh.info)\n\nIn der Regel melden wir uns innerhalb weniger Tage zurück.\n\n::: hinweis\nBei dringenden schulischen Angelegenheiten wenden Sie sich bitte direkt an das Sekretariat der IGMH.\n:::","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 MSLine truncated
|
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.\n\n::: kennzahlen\n- **seit 1975** im Einsatz für die IGMH\n- **über 500** Mitglieder\n- **ab 10 €** Jahresbeitrag\n:::\n\n::: hinweis\nDer Förderverein ist ein eigenständiger, gemeinnützig anerkannter Verein und nicht Teil des Elternbeirats.\n:::\n\n## Was der Verein fördert\n\n::: kacheln\n- **Pädagogik** Bessere Rahmenbedingungen für die Arbeit an der Schule\n- **Projekte** Musische, sportliche und kulturelle Vorhaben\n- **Ausstattung** Anschaffungen, die allen Schülerinnen und Schülern zugutekommen\n- **Chancengerechtigkeit** Damit kein Kind aus finanziellen Gründen zurückbleibt\n:::\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\n::: aufruf\nDie Beitrittserklärung füllen Sie online beim Förderverein aus.\n\n[Zur Beitrittserklärung](https://easyverein.com/public/FDI/applicationform/21612)\n:::\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":"Formulare und Dokumente zum Herunterladen:\n\n- [Aufnahmeantrag Förderverein](/dokumente/aufnahmeantrag-foerderverein.pdf)\n- [Einzugsermächtigung](/dokumente/einzugsermaechtigung.pdf)\n- [Erstattungsformular Klassenkasse](/dokumente/erstattungsformular-klassenkasse.pdf)\n\n::: hinweis\nFehlt ein Dokument? Melden Sie sich über die [Kontaktseite](/contact).\n:::","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\n[kontakt@elternbeirat-igmh.info](mailto:kontakt@elternbeirat-igmh.info)\n\nIn der Regel melden wir uns innerhalb weniger Tage zurück.\n\n::: hinweis\nBei dringenden schulischen Angelegenheiten wenden Sie sich bitte direkt an das Sekretariat der IGMH.\n:::","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 MSLine truncated
|
||||||
,
|
,
|
||||||
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
|
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
|
||||||
,
|
,
|
||||||
|
|||||||
Reference in new issue
Block a user