Compare commits
21
Commits
a641beab16
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
53cee04396 | ||
|
|
ee69892058 | ||
|
|
a847507b46 | ||
|
|
7d09233ca2 | ||
|
|
8e4333b3ac | ||
|
|
ebb3b387c5 | ||
|
|
7e56e83953 | ||
|
|
ed6aa5d5ec | ||
|
|
ba65b18d29 | ||
|
|
ae362639e3 | ||
|
|
0257b2e30b | ||
|
|
602f258257 | ||
|
|
a5a68ed122 | ||
|
|
efb6fa9109 | ||
|
|
b2dc033de1 | ||
|
|
d328adac5c | ||
|
|
6e5a30607e | ||
|
|
c24ca16a41 | ||
|
|
e51afeb34b | ||
|
|
b737f57ef7 | ||
|
|
d539fde09d |
No files matched your search
@@ -0,0 +1,105 @@
|
|||||||
|
using Elternbeirat.Contracts;
|
||||||
|
using Elternbeirat.Web.Features.Events;
|
||||||
|
|
||||||
|
namespace Elternbeirat.Web.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Unit tests for the calendar sheet texts and the time line beside it. Pure logic,
|
||||||
|
/// no PocketBase needed. All times are Berlin wall-clock, as the contract delivers them.
|
||||||
|
/// </summary>
|
||||||
|
public class EventDateFormatTests
|
||||||
|
{
|
||||||
|
private static Event Event(DateTime start, DateTime? end = null) =>
|
||||||
|
new() { Id = "test", Title = "Termin", Start = start, End = end };
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(1, "Jan")]
|
||||||
|
[InlineData(3, "März")]
|
||||||
|
[InlineData(5, "Mai")]
|
||||||
|
[InlineData(10, "Okt")]
|
||||||
|
[InlineData(12, "Dez")]
|
||||||
|
public void Sheet_month_is_the_German_short_month_without_period(int month, string expected) =>
|
||||||
|
EventDateFormat.SheetMonth(new DateTime(2026, month, 1)).ShouldBe(expected);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Sheet_day_has_no_leading_zero() =>
|
||||||
|
EventDateFormat.SheetDay(new DateTime(2026, 10, 8, 19, 30, 0)).ShouldBe("8");
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(8, "Do")] // Thursday, 8 October 2026
|
||||||
|
[InlineData(11, "So")] // Sunday
|
||||||
|
public void Sheet_weekday_is_the_German_short_weekday_without_period(int day, string expected) =>
|
||||||
|
EventDateFormat.SheetWeekday(new DateTime(2026, 10, day)).ShouldBe(expected);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Past_all_day_event_shows_the_day_and_no_time()
|
||||||
|
{
|
||||||
|
var allDay = Event(new DateTime(2026, 11, 13));
|
||||||
|
|
||||||
|
EventDateFormat.PastDays(allDay).ShouldBe("13. November");
|
||||||
|
EventDateFormat.PastTime(allDay).ShouldBe("");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Past_timed_event_shows_the_day_and_its_start_time()
|
||||||
|
{
|
||||||
|
var timed = Event(new DateTime(2026, 9, 1, 19, 0, 0));
|
||||||
|
|
||||||
|
EventDateFormat.PastDays(timed).ShouldBe("1. September"); // no leading zero
|
||||||
|
EventDateFormat.PastTime(timed).ShouldBe("19:00 Uhr");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Past_timed_event_ending_the_same_day_shows_the_time_range()
|
||||||
|
{
|
||||||
|
var evening = Event(new DateTime(2026, 11, 13, 19, 0, 0), new DateTime(2026, 11, 13, 21, 0, 0));
|
||||||
|
|
||||||
|
EventDateFormat.PastDays(evening).ShouldBe("13. November");
|
||||||
|
EventDateFormat.PastTime(evening).ShouldBe("19:00–21:00 Uhr");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Past_multi_day_event_in_one_month_names_the_month_once() =>
|
||||||
|
EventDateFormat.PastDays(Event(new DateTime(2026, 2, 8), new DateTime(2026, 2, 9))).ShouldBe("8.–9. Februar");
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Past_multi_day_event_across_a_month_names_both_months() =>
|
||||||
|
EventDateFormat.PastDays(Event(new DateTime(2026, 1, 30), new DateTime(2026, 2, 2)))
|
||||||
|
.ShouldBe("30. Januar – 2. Februar");
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Past_multi_day_event_across_a_year_names_the_end_year() =>
|
||||||
|
// The list is grouped by the start year, so only the end needs its year.
|
||||||
|
EventDateFormat.PastDays(Event(new DateTime(2026, 12, 30), new DateTime(2027, 1, 2)))
|
||||||
|
.ShouldBe("30. Dezember – 2. Januar 2027");
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Past_timed_multi_day_event_shows_only_the_start_time() =>
|
||||||
|
// A range here would read as the daily hours.
|
||||||
|
EventDateFormat.PastTime(Event(new DateTime(2026, 2, 8, 9, 0, 0), new DateTime(2026, 2, 9, 13, 0, 0)))
|
||||||
|
.ShouldBe("09:00 Uhr");
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Span_of_a_timed_event_without_end_is_its_start_time() =>
|
||||||
|
EventDateFormat.Span(Event(new DateTime(2026, 10, 8, 19, 30, 0))).ShouldBe("19:30 Uhr");
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Span_of_a_same_day_event_is_the_time_range() =>
|
||||||
|
EventDateFormat.Span(Event(new DateTime(2026, 10, 8, 19, 30, 0), new DateTime(2026, 10, 8, 21, 0, 0)))
|
||||||
|
.ShouldBe("19:30–21:00 Uhr");
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Span_of_a_multi_day_event_names_the_last_day() =>
|
||||||
|
EventDateFormat.Span(Event(new DateTime(2026, 10, 9, 9, 0, 0), new DateTime(2026, 10, 11, 13, 0, 0)))
|
||||||
|
.ShouldBe("09:00 Uhr bis Sonntag, 11. Oktober 2026, 13:00 Uhr");
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Span_of_a_multi_day_all_day_event_has_no_times() =>
|
||||||
|
EventDateFormat.Span(Event(new DateTime(2026, 10, 9), new DateTime(2026, 10, 11)))
|
||||||
|
.ShouldBe("bis Sonntag, 11. Oktober 2026");
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Span_of_a_single_all_day_event_is_empty() =>
|
||||||
|
// The sheet already shows the day; there is nothing left to say.
|
||||||
|
EventDateFormat.Span(Event(new DateTime(2026, 10, 8))).ShouldBe("");
|
||||||
|
}
|
||||||
@@ -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,27 @@
|
|||||||
|
using Elternbeirat.Web.Features.Layout;
|
||||||
|
|
||||||
|
namespace Elternbeirat.Web.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Unit tests for the footer's copyright year. Pure logic, no PocketBase needed.
|
||||||
|
/// </summary>
|
||||||
|
public class FooterYearTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void New_year_in_Berlin_wins_over_the_UTC_year()
|
||||||
|
{
|
||||||
|
// 23:30 UTC on New Year's Eve is already 00:30 in Berlin (winter time, +1),
|
||||||
|
// so the footer must show the new year although the server clock says the old.
|
||||||
|
var now = new DateTimeOffset(2026, 12, 31, 23, 30, 0, TimeSpan.Zero);
|
||||||
|
|
||||||
|
FooterYear.For(now).ShouldBe(2027);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Ordinary_day_keeps_its_year()
|
||||||
|
{
|
||||||
|
var now = new DateTimeOffset(2026, 10, 1, 12, 0, 0, TimeSpan.Zero);
|
||||||
|
|
||||||
|
FooterYear.For(now).ShouldBe(2026);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,110 @@ 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("");
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(null)]
|
||||||
|
[InlineData("")]
|
||||||
|
[InlineData(" ")]
|
||||||
|
[InlineData("## Nur eine Überschrift")]
|
||||||
|
public void Teaser_is_empty_without_paragraph_text(string? markdown) =>
|
||||||
|
Markdown.Teaser(markdown).ShouldBe("");
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Teaser_keeps_a_short_body_whole() =>
|
||||||
|
Markdown.Teaser("Kurzer Text.").ShouldBe("Kurzer Text.");
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Teaser_removes_markdown_and_skips_headings()
|
||||||
|
{
|
||||||
|
// Emphasis and link markup go, link text stays; the heading would repeat the
|
||||||
|
// card title, so it is left out. Paragraphs and list items are joined.
|
||||||
|
var teaser = Markdown.Teaser(
|
||||||
|
"## Rückblick\n\nDer **Basar** war ein [Erfolg](/posts).\n\n- Danke an alle\n- Bis bald");
|
||||||
|
|
||||||
|
teaser.ShouldBe("Der Basar war ein Erfolg. Danke an alle Bis bald");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Teaser_cuts_a_long_body_at_a_word_boundary()
|
||||||
|
{
|
||||||
|
var teaser = Markdown.Teaser("Der Basar war ein voller Erfolg.", 20);
|
||||||
|
|
||||||
|
// 20 characters end inside "voller"; the cut falls back to the space before it.
|
||||||
|
teaser.ShouldBe("Der Basar war ein…");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Teaser_keeps_a_word_that_ends_exactly_at_the_limit() =>
|
||||||
|
Markdown.Teaser("Der Basar war ein voller Erfolg.", 17).ShouldBe("Der Basar war ein…");
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Teaser_drops_punctuation_left_at_the_cut() =>
|
||||||
|
Markdown.Teaser("Liebe Eltern, der Basar war schön.", 14).ShouldBe("Liebe Eltern…");
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Teaser_cuts_a_single_overlong_word_hard() =>
|
||||||
|
Markdown.Teaser("Donaudampfschifffahrtsgesellschaft", 10).ShouldBe("Donaudampf…");
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Teaser_defaults_to_160_characters_plus_ellipsis()
|
||||||
|
{
|
||||||
|
var body = string.Join(' ', Enumerable.Repeat("Wort", 100)); // 499 characters
|
||||||
|
|
||||||
|
var teaser = Markdown.Teaser(body);
|
||||||
|
|
||||||
|
teaser.Length.ShouldBeLessThanOrEqualTo(Markdown.TeaserLength + 1);
|
||||||
|
teaser.ShouldEndWith("Wort…");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Teaser_rejects_a_length_of_zero() =>
|
||||||
|
Should.Throw<ArgumentOutOfRangeException>(() => Markdown.Teaser("Text", 0));
|
||||||
}
|
}
|
||||||
@@ -43,6 +43,15 @@ public class NavBuilderTests
|
|||||||
footer.Select(link => link.Title).ShouldBe(["Impressum", "Datenschutz"]);
|
footer.Select(link => link.Title).ShouldBe(["Impressum", "Datenschutz"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void No_pages_give_empty_menus()
|
||||||
|
{
|
||||||
|
// PocketBase down or nothing published yet: both menus are empty lists, not
|
||||||
|
// null, so the layout can simply skip a column instead of crashing.
|
||||||
|
NavBuilder.Header([]).ShouldBeEmpty();
|
||||||
|
NavBuilder.Footer([]).ShouldBeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Home_page_maps_to_root_every_other_slug_to_its_path()
|
public void Home_page_maps_to_root_every_other_slug_to_its_path()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -17,8 +17,8 @@ public sealed class PocketBaseClientTests(PocketBaseFixture pocketBase)
|
|||||||
|
|
||||||
var events = await client.GetEventsAsync();
|
var events = await client.GetEventsAsync();
|
||||||
|
|
||||||
// Two seeded events, earliest start first.
|
// Three seeded events, earliest start first.
|
||||||
events.Select(@event => @event.Title).ShouldBe(["Elternbeiratssitzung", "Herbstbasar"]);
|
events.Select(@event => @event.Title).ShouldBe(["Projekttage", "Infoabend Klasse 5", "Elternbeiratssitzung", "Herbstbasar"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -226,12 +226,25 @@ 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
|
||||||
{
|
{
|
||||||
title = "Vorstandsteam", body = "# Vorstand", location = "header",
|
// The body uses the "::: team" block like production: one card with the
|
||||||
|
// full convention (bold name, role, italic duties, note) in a large block,
|
||||||
|
// and one item without the convention, which must become a plain card.
|
||||||
|
title = "Vorstandsteam",
|
||||||
|
body = "## Vorsitz\n\n::: team gross\n"
|
||||||
|
+ "- **Jenny Reger-Stilgenbauer** (Vorsitzende) \n *Schulkonferenz, Mensarat* \n Mitglied im Gesamtelternbeirat.\n"
|
||||||
|
+ ":::\n\n## Beisitz\n\n::: team\n"
|
||||||
|
+ "- Özlem Ünal, Beisitz\n"
|
||||||
|
+ ":::",
|
||||||
|
location = "header",
|
||||||
order = 2, slug = "board", embed = Array.Empty<string>(), @public = true,
|
order = 2, slug = "board", embed = Array.Empty<string>(), @public = true,
|
||||||
});
|
});
|
||||||
await CreateRecordAsync(http, "pages", new
|
await CreateRecordAsync(http, "pages", new
|
||||||
@@ -334,6 +347,20 @@ public sealed class PocketBaseFixture : IAsyncLifetime
|
|||||||
start = "2026-11-22 08:00:00.000Z", end = "2026-11-22 12:00:00.000Z",
|
start = "2026-11-22 08:00:00.000Z", end = "2026-11-22 12:00:00.000Z",
|
||||||
title = "Herbstbasar", location = "Schulhof", note = "", @public = true,
|
title = "Herbstbasar", location = "Schulhof", note = "", @public = true,
|
||||||
});
|
});
|
||||||
|
// Two past events (before the smoke tests' clock of 1 October 2026), so the
|
||||||
|
// folded-up "Vergangene Termine" section on /events has something to show:
|
||||||
|
// one with a time (19:00 Berlin, summer time), and one over several days
|
||||||
|
// without a time. All-day means 00:00 Berlin, i.e. 22:00Z the day before.
|
||||||
|
await CreateRecordAsync(http, "events", new
|
||||||
|
{
|
||||||
|
start = "2026-09-10 17:00:00.000Z", title = "Infoabend Klasse 5",
|
||||||
|
location = "Aula", note = "", @public = true,
|
||||||
|
});
|
||||||
|
await CreateRecordAsync(http, "events", new
|
||||||
|
{
|
||||||
|
start = "2026-07-19 22:00:00.000Z", end = "2026-07-21 22:00:00.000Z",
|
||||||
|
title = "Projekttage", location = "", note = "", @public = true,
|
||||||
|
});
|
||||||
|
|
||||||
// Faq topics: two, ordered. The topics own the grouping and the order the FAQ
|
// Faq topics: two, ordered. The topics own the grouping and the order the FAQ
|
||||||
// page shows the groups in (cafeteria before lockers). Titles stay German for
|
// page shows the groups in (cafeteria before lockers). Titles stay German for
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
using Elternbeirat.Web.Shared;
|
||||||
|
|
||||||
|
namespace Elternbeirat.Web.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Unit tests for the health cache behind the maintenance gate. The probe is a fake
|
||||||
|
/// that counts its calls, so no PocketBase is needed.
|
||||||
|
/// </summary>
|
||||||
|
public class PocketBaseHealthCacheTests
|
||||||
|
{
|
||||||
|
private readonly SteppingClock _clock = new(new DateTimeOffset(2026, 10, 1, 10, 0, 0, TimeSpan.Zero));
|
||||||
|
private int _probes;
|
||||||
|
|
||||||
|
private Func<CancellationToken, Task<bool>> Probe(bool healthy) =>
|
||||||
|
_ =>
|
||||||
|
{
|
||||||
|
_probes++;
|
||||||
|
return Task.FromResult(healthy);
|
||||||
|
};
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Result_is_reused_within_the_lifetime()
|
||||||
|
{
|
||||||
|
var cache = new PocketBaseHealthCache(_clock);
|
||||||
|
|
||||||
|
await cache.IsHealthyAsync(Probe(healthy: true), CancellationToken.None);
|
||||||
|
_clock.Now += PocketBaseHealthCache.Lifetime - TimeSpan.FromSeconds(1);
|
||||||
|
var healthy = await cache.IsHealthyAsync(Probe(healthy: false), CancellationToken.None);
|
||||||
|
|
||||||
|
healthy.ShouldBeTrue();
|
||||||
|
_probes.ShouldBe(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Stale_result_is_probed_again()
|
||||||
|
{
|
||||||
|
// The way back after an outage: once the lifetime is over, the new answer
|
||||||
|
// replaces the old one.
|
||||||
|
var cache = new PocketBaseHealthCache(_clock);
|
||||||
|
|
||||||
|
await cache.IsHealthyAsync(Probe(healthy: false), CancellationToken.None);
|
||||||
|
_clock.Now += PocketBaseHealthCache.Lifetime;
|
||||||
|
var healthy = await cache.IsHealthyAsync(Probe(healthy: true), CancellationToken.None);
|
||||||
|
|
||||||
|
healthy.ShouldBeTrue();
|
||||||
|
_probes.ShouldBe(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Unhealthy_result_is_cached_too()
|
||||||
|
{
|
||||||
|
// While PocketBase is down, not every request should wait for a failing probe.
|
||||||
|
var cache = new PocketBaseHealthCache(_clock);
|
||||||
|
|
||||||
|
await cache.IsHealthyAsync(Probe(healthy: false), CancellationToken.None);
|
||||||
|
var healthy = await cache.IsHealthyAsync(Probe(healthy: true), CancellationToken.None);
|
||||||
|
|
||||||
|
healthy.ShouldBeFalse();
|
||||||
|
_probes.ShouldBe(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A clock the test moves forward by hand, to step past the cache lifetime without
|
||||||
|
/// waiting.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="start">The moment the clock shows at first.</param>
|
||||||
|
private sealed class SteppingClock(DateTimeOffset start) : TimeProvider
|
||||||
|
{
|
||||||
|
public DateTimeOffset Now { get; set; } = start;
|
||||||
|
|
||||||
|
public override DateTimeOffset GetUtcNow() => Now;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
using Elternbeirat.Contracts;
|
||||||
|
using Elternbeirat.Web.Features.Posts;
|
||||||
|
|
||||||
|
namespace Elternbeirat.Web.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Unit tests for the older/newer post next to a post. The list comes newest
|
||||||
|
/// first, as from PocketBase; "previous" is the older post. Pure logic, no
|
||||||
|
/// PocketBase needed.
|
||||||
|
/// </summary>
|
||||||
|
public class PostNeighborsTests
|
||||||
|
{
|
||||||
|
// Newest first, like PocketBaseClient.GetPostsAsync.
|
||||||
|
private static readonly Post[] Posts = [Post("march"), Post("february"), Post("january")];
|
||||||
|
|
||||||
|
private static Post Post(string slug) =>
|
||||||
|
new() { Id = slug, Title = slug, Slug = slug };
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Middle_post_has_the_older_as_previous_and_the_newer_as_next()
|
||||||
|
{
|
||||||
|
var neighbors = PostNeighbors.Of(Posts, "february");
|
||||||
|
|
||||||
|
neighbors.Previous?.Slug.ShouldBe("january");
|
||||||
|
neighbors.Next?.Slug.ShouldBe("march");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Newest_post_has_no_next()
|
||||||
|
{
|
||||||
|
var neighbors = PostNeighbors.Of(Posts, "march");
|
||||||
|
|
||||||
|
neighbors.Previous?.Slug.ShouldBe("february");
|
||||||
|
neighbors.Next.ShouldBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Oldest_post_has_no_previous()
|
||||||
|
{
|
||||||
|
var neighbors = PostNeighbors.Of(Posts, "january");
|
||||||
|
|
||||||
|
neighbors.Previous.ShouldBeNull();
|
||||||
|
neighbors.Next?.Slug.ShouldBe("february");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Only_post_has_no_neighbors() =>
|
||||||
|
PostNeighbors.Of([Post("only")], "only").ShouldBe(new PostNeighbors(null, null));
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Unknown_slug_has_no_neighbors() =>
|
||||||
|
PostNeighbors.Of(Posts, "december").ShouldBe(new PostNeighbors(null, null));
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Slug_is_compared_case_insensitively() =>
|
||||||
|
PostNeighbors.Of(Posts, "February").Next?.Slug.ShouldBe("march");
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
@@ -43,7 +60,16 @@ public sealed partial class RouteSmokeTests : IDisposable
|
|||||||
/// Every route that a visitor can reach through the navigation, the FAQ hub
|
/// Every route that a visitor can reach through the navigation, the FAQ hub
|
||||||
/// or the news section. The post slugs match the fixture's seed.
|
/// or the news section. The post slugs match the fixture's seed.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static TheoryData<string> KnownRoutes =>
|
public static TheoryData<string> KnownRoutes => [.. Routes];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The <see cref="KnownRoutes"/> that render an HTML page, i.e. all but the
|
||||||
|
/// calendar feed.
|
||||||
|
/// </summary>
|
||||||
|
public static TheoryData<string> PageRoutes =>
|
||||||
|
[.. Routes.Where(route => !route.EndsWith(".ics", StringComparison.Ordinal))];
|
||||||
|
|
||||||
|
private static readonly string[] Routes =
|
||||||
[
|
[
|
||||||
"/",
|
"/",
|
||||||
"/board",
|
"/board",
|
||||||
@@ -75,6 +101,7 @@ public sealed partial class RouteSmokeTests : IDisposable
|
|||||||
[Theory]
|
[Theory]
|
||||||
[InlineData("/gibt-es-nicht")]
|
[InlineData("/gibt-es-nicht")]
|
||||||
[InlineData("/posts/gibt-es-nicht")]
|
[InlineData("/posts/gibt-es-nicht")]
|
||||||
|
[InlineData("/events/gibt-es-nicht.ics")]
|
||||||
public async Task Unknown_route_returns_404(string route)
|
public async Task Unknown_route_returns_404(string route)
|
||||||
{
|
{
|
||||||
var client = _factory.CreateClient();
|
var client = _factory.CreateClient();
|
||||||
@@ -115,6 +142,30 @@ public sealed partial class RouteSmokeTests : IDisposable
|
|||||||
html.ShouldNotContain("href=\"/draft\""); // public=false, filtered out
|
html.ShouldNotContain("href=\"/draft\""); // public=false, filtered out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Footer_links_every_public_header_and_footer_page_in_order()
|
||||||
|
{
|
||||||
|
// The footer repeats the header pages as "Schnellzugriff" and then lists the
|
||||||
|
// footer pages, each column in its order field. Only the <footer> is checked,
|
||||||
|
// so the header menu cannot make the test pass on its own. The expected list
|
||||||
|
// mirrors the fixture's pages; the draft (public=false) must be missing.
|
||||||
|
var client = _factory.CreateClient();
|
||||||
|
|
||||||
|
var html = await client.GetStringAsync(new Uri("/", UriKind.Relative));
|
||||||
|
|
||||||
|
var footer = FooterElement().Match(html);
|
||||||
|
footer.Success.ShouldBeTrue();
|
||||||
|
var hrefs = Href().Matches(footer.Value).Select(match => match.Groups[1].Value);
|
||||||
|
|
||||||
|
hrefs.ShouldBe(
|
||||||
|
[
|
||||||
|
"/", "/board", "/patrons", "/faqs", "/downloads", "/posts", "/events",
|
||||||
|
"/contact", "/imprint", "/privacy", "/faq-lunch",
|
||||||
|
]);
|
||||||
|
footer.Value.ShouldContain("Schnellzugriff");
|
||||||
|
footer.Value.ShouldContain("Weiteres");
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Home_renders_the_embeds_its_page_opts_into()
|
public async Task Home_renders_the_embeds_its_page_opts_into()
|
||||||
{
|
{
|
||||||
@@ -156,6 +207,28 @@ public sealed partial class RouteSmokeTests : IDisposable
|
|||||||
html.ShouldContain("<div class=\"kennzahlen\">");
|
html.ShouldContain("<div class=\"kennzahlen\">");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Board_renders_team_block_as_person_cards()
|
||||||
|
{
|
||||||
|
// The board body in the fixture holds a "::: team gross" block with one card
|
||||||
|
// following the convention and a "::: team" block with one that does not.
|
||||||
|
// Assert the cards survive the whole path: PocketBase, page, Markdown, HTML.
|
||||||
|
var client = _factory.CreateClient();
|
||||||
|
|
||||||
|
var html = await client.GetStringAsync(new Uri("/board", UriKind.Relative));
|
||||||
|
|
||||||
|
html.ShouldContain("<div class=\"team gross\">");
|
||||||
|
html.ShouldContain("<span class=\"team-avatar team-avatar-");
|
||||||
|
html.ShouldContain("aria-hidden=\"true\">JR</span>");
|
||||||
|
html.ShouldContain("<strong class=\"team-name\">Jenny Reger-Stilgenbauer</strong>");
|
||||||
|
html.ShouldContain("<span class=\"team-role\">Vorsitzende</span>");
|
||||||
|
html.ShouldContain("<span class=\"team-duty\">Schulkonferenz</span>");
|
||||||
|
html.ShouldContain("<span class=\"team-duty\">Mensarat</span>");
|
||||||
|
html.ShouldContain("<span class=\"team-note\">Mitglied im Gesamtelternbeirat.</span>");
|
||||||
|
html.ShouldContain("<li class=\"team-card team-card-plain\">Özlem Ünal, Beisitz</li>");
|
||||||
|
html.ShouldContain("<h2>Beisitz</h2>"); // the group headings stay normal headings
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Posts_and_events_render_their_page_heading_and_body()
|
public async Task Posts_and_events_render_their_page_heading_and_body()
|
||||||
{
|
{
|
||||||
@@ -235,6 +308,279 @@ public sealed partial class RouteSmokeTests : IDisposable
|
|||||||
var response = await client.GetAsync(new Uri("/health", UriKind.Relative));
|
var response = await client.GetAsync(new Uri("/health", UriKind.Relative));
|
||||||
|
|
||||||
response.StatusCode.ShouldBe(HttpStatusCode.ServiceUnavailable);
|
response.StatusCode.ShouldBe(HttpStatusCode.ServiceUnavailable);
|
||||||
|
(await response.Content.ReadAsStringAsync()).ShouldBe("PocketBase unreachable");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("/")]
|
||||||
|
[InlineData("/board")]
|
||||||
|
[InlineData("/posts/new-board")]
|
||||||
|
public async Task Page_shows_maintenance_page_when_PocketBase_is_unreachable(string route)
|
||||||
|
{
|
||||||
|
// The maintenance gate: with PocketBase down, a page is not rendered half-empty
|
||||||
|
// but answered with the static maintenance.html, a 503 and a hint when to retry.
|
||||||
|
await using var factory = UnreachablePocketBase();
|
||||||
|
var client = factory.CreateClient();
|
||||||
|
|
||||||
|
var response = await client.GetAsync(new Uri(route, UriKind.Relative));
|
||||||
|
|
||||||
|
response.StatusCode.ShouldBe(HttpStatusCode.ServiceUnavailable);
|
||||||
|
response.Headers.RetryAfter.ShouldNotBeNull();
|
||||||
|
(await response.Content.ReadAsStringAsync()).ShouldContain("Die Seite ist gerade nicht erreichbar");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Calendar_feed_stays_an_empty_calendar_when_PocketBase_is_unreachable()
|
||||||
|
{
|
||||||
|
// Not a page, so the gate lets it through: subscribed calendar apps keep
|
||||||
|
// getting a valid (empty) calendar instead of an HTML page.
|
||||||
|
await using var factory = UnreachablePocketBase();
|
||||||
|
var client = factory.CreateClient();
|
||||||
|
|
||||||
|
var response = await client.GetAsync(new Uri("/events.ics", UriKind.Relative));
|
||||||
|
|
||||||
|
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||||
|
(await response.Content.ReadAsStringAsync()).ShouldContain("BEGIN:VCALENDAR");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Maintenance_page_and_its_font_stay_reachable_when_PocketBase_is_unreachable()
|
||||||
|
{
|
||||||
|
// Static files are not gated: the maintenance page loads its self-hosted font
|
||||||
|
// from /fonts while PocketBase is down.
|
||||||
|
await using var factory = UnreachablePocketBase();
|
||||||
|
var client = factory.CreateClient();
|
||||||
|
|
||||||
|
var page = await client.GetAsync(new Uri("/maintenance.html", UriKind.Relative));
|
||||||
|
var font = await client.GetAsync(new Uri("/fonts/nunito-latin-wght.woff2", UriKind.Relative));
|
||||||
|
|
||||||
|
page.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||||
|
font.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A throwaway app pointed at a dead address: nothing listens on port 1, so every
|
||||||
|
/// PocketBase call is refused at once, without a timeout wait.
|
||||||
|
/// </summary>
|
||||||
|
private WebApplicationFactory<Program> UnreachablePocketBase() =>
|
||||||
|
_baseFactory.WithWebHostBuilder(builder =>
|
||||||
|
builder.UseSetting("PocketBase:BaseUrl", "http://localhost:1"));
|
||||||
|
|
||||||
|
[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");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Event_cards_on_the_events_page_do_not_link_to_the_events_page()
|
||||||
|
{
|
||||||
|
// Events have no page of their own; a card linking to /events led the
|
||||||
|
// visitor back to where they already were. The navigation does link to
|
||||||
|
// /events, so only the card list is checked.
|
||||||
|
var client = _factory.CreateClient();
|
||||||
|
|
||||||
|
var html = await client.GetStringAsync(new Uri("/events", UriKind.Relative));
|
||||||
|
|
||||||
|
var list = EventCards().Match(html);
|
||||||
|
list.Success.ShouldBeTrue();
|
||||||
|
Href().Matches(list.Value).Select(match => match.Groups[1].Value).ShouldAllBe(href => href.EndsWith(".ics", StringComparison.Ordinal));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Events_page_shows_its_intro_once_and_a_subscribe_button()
|
||||||
|
{
|
||||||
|
// The intro is the "events" page body; a hard-coded intro beside it used to
|
||||||
|
// say the same thing twice.
|
||||||
|
var client = _factory.CreateClient();
|
||||||
|
|
||||||
|
var html = await client.GetStringAsync(new Uri("/events", UriKind.Relative));
|
||||||
|
|
||||||
|
Regex.Count(html, Regex.Escape("Sitzungen und Veranstaltungen auf einen Blick.")).ShouldBe(1);
|
||||||
|
html.ShouldContain("""<a class="btn btn-secondary" href="/events.ics">""");
|
||||||
|
html.ShouldContain("Kalender abonnieren");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Events_page_folds_past_events_away()
|
||||||
|
{
|
||||||
|
var client = _factory.CreateClient();
|
||||||
|
|
||||||
|
var html = await client.GetStringAsync(new Uri("/events", UriKind.Relative));
|
||||||
|
|
||||||
|
var past = PastEvents().Match(html);
|
||||||
|
past.Success.ShouldBeTrue();
|
||||||
|
past.Value.ShouldContain("Vergangene Termine anzeigen (2)");
|
||||||
|
past.Value.ShouldContain("Infoabend Klasse 5");
|
||||||
|
past.Value.ShouldNotContain("Elternbeiratssitzung"); // upcoming, not past
|
||||||
|
past.Value.ShouldContain("""<h3 class="past-events-year">2026</h3>""");
|
||||||
|
past.Value.ShouldContain("10. September"); // day without weekday or year
|
||||||
|
past.Value.ShouldContain("19:00 Uhr"); // the time stays, after the title
|
||||||
|
past.Value.ShouldContain("20.–22. Juli"); // several days as a span
|
||||||
|
Regex.Count(past.Value, "Uhr").ShouldBe(1); // the all-day Projekttage get no time
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Single_event_calendar_file_holds_exactly_that_event()
|
||||||
|
{
|
||||||
|
// The "In Kalender übernehmen" link of a card leads to a calendar with just
|
||||||
|
// that one event. Take the link from the page, as a visitor would.
|
||||||
|
var client = _factory.CreateClient();
|
||||||
|
var html = await client.GetStringAsync(new Uri("/events", UriKind.Relative));
|
||||||
|
var href = Href().Matches(EventCards().Match(html).Value)
|
||||||
|
.Select(match => match.Groups[1].Value)
|
||||||
|
.First();
|
||||||
|
|
||||||
|
var response = await client.GetAsync(new Uri(href, UriKind.Relative));
|
||||||
|
|
||||||
|
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||||
|
response.Content.Headers.ContentType?.MediaType.ShouldBe("text/calendar");
|
||||||
|
var ics = await response.Content.ReadAsStringAsync();
|
||||||
|
ics.ShouldStartWith("BEGIN:VCALENDAR");
|
||||||
|
Regex.Count(ics, "BEGIN:VEVENT").ShouldBe(1);
|
||||||
|
ics.ShouldContain("SUMMARY:Elternbeiratssitzung"); // the earliest upcoming card
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Home_event_cards_link_to_the_events_page()
|
||||||
|
{
|
||||||
|
// On the home page the cards are a teaser and may lead to the full list.
|
||||||
|
var client = _factory.CreateClient();
|
||||||
|
|
||||||
|
var html = await client.GetStringAsync(new Uri("/", UriKind.Relative));
|
||||||
|
|
||||||
|
var list = EventCards().Match(html);
|
||||||
|
list.Success.ShouldBeTrue();
|
||||||
|
Href().Matches(list.Value).Select(match => match.Groups[1].Value).ShouldAllBe(href => href == "/events");
|
||||||
|
html.ShouldContain("class=\"calendar-sheet\"");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Home_next_event_shows_the_time_and_keeps_the_full_date_for_screen_readers()
|
||||||
|
{
|
||||||
|
// The calendar sheet already shows the day, so the written-out date is only
|
||||||
|
// for screen readers; visible beside the sheet is the time. The meeting is
|
||||||
|
// 19:30 Berlin on Thursday, 8 October 2026 (fixture, test clock 1 October).
|
||||||
|
var client = _factory.CreateClient();
|
||||||
|
|
||||||
|
var html = await client.GetStringAsync(new Uri("/", UriKind.Relative));
|
||||||
|
|
||||||
|
var card = NextEventCard().Match(html);
|
||||||
|
card.Success.ShouldBeTrue();
|
||||||
|
var hidden = VisuallyHidden().Match(card.Value);
|
||||||
|
hidden.Value.ShouldContain("Donnerstag, 8. Oktober 2026, 19:30 Uhr");
|
||||||
|
var visible = card.Value.Replace(hidden.Value, "", StringComparison.Ordinal);
|
||||||
|
visible.ShouldContain("19:30 Uhr");
|
||||||
|
visible.ShouldNotContain("Oktober 2026");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Post_page_has_the_title_as_its_only_h1()
|
||||||
|
{
|
||||||
|
var client = _factory.CreateClient();
|
||||||
|
|
||||||
|
var html = await client.GetStringAsync(new Uri("/posts/new-board", UriKind.Relative));
|
||||||
|
|
||||||
|
Regex.Count(html, "<h1[ >]").ShouldBe(1);
|
||||||
|
html.ShouldContain(">Neuer Vorstand</h1>");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Newest_post_links_only_to_the_older_one()
|
||||||
|
{
|
||||||
|
// The fixture has two posts: new-board (newer) and new-hall (older).
|
||||||
|
// "Vorheriger Beitrag" is the older post, so the newest post has no "next".
|
||||||
|
var client = _factory.CreateClient();
|
||||||
|
|
||||||
|
var html = await client.GetStringAsync(new Uri("/posts/new-board", UriKind.Relative));
|
||||||
|
|
||||||
|
html.ShouldContain("href=\"/posts/new-hall\" rel=\"prev\"");
|
||||||
|
html.ShouldNotContain("rel=\"next\"");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Oldest_post_links_only_to_the_newer_one()
|
||||||
|
{
|
||||||
|
var client = _factory.CreateClient();
|
||||||
|
|
||||||
|
var html = await client.GetStringAsync(new Uri("/posts/new-hall", UriKind.Relative));
|
||||||
|
|
||||||
|
html.ShouldContain("href=\"/posts/new-board\" rel=\"next\"");
|
||||||
|
html.ShouldNotContain("rel=\"prev\"");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[MemberData(nameof(PageRoutes))]
|
||||||
|
public async Task Every_page_starts_with_a_skip_link_to_its_own_content(string route)
|
||||||
|
{
|
||||||
|
// The skip link must carry the page's own path: with <base href="/"> a bare
|
||||||
|
// "#main" would lead back to the home page. It must also bypass Blazor's
|
||||||
|
// enhanced navigation, which scrolls but does not move the focus.
|
||||||
|
var client = _factory.CreateClient();
|
||||||
|
|
||||||
|
var html = await client.GetStringAsync(new Uri(route, UriKind.Relative));
|
||||||
|
|
||||||
|
html.ShouldContain($"""<a class="skip-link" href="{route}#main" data-enhance-nav="false" """);
|
||||||
|
html.ShouldContain("Zum Inhalt springen</a>");
|
||||||
|
html.ShouldContain("""<main id="main" """);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Menu_toggle_is_a_named_checkbox_tied_to_the_menu()
|
||||||
|
{
|
||||||
|
// The CSS-only hamburger: the checkbox is what keyboard and screen-reader
|
||||||
|
// users operate, so it needs a name and must point at the menu it opens.
|
||||||
|
var client = _factory.CreateClient();
|
||||||
|
|
||||||
|
var html = await client.GetStringAsync(new Uri("/", UriKind.Relative));
|
||||||
|
|
||||||
|
html.ShouldContain("aria-controls=\"main-nav\"");
|
||||||
|
html.ShouldContain("Hauptmenü anzeigen</span>");
|
||||||
|
html.ShouldContain("<nav id=\"main-nav\"");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -245,4 +591,50 @@ public sealed partial class RouteSmokeTests : IDisposable
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
[GeneratedRegex("""(?:\b(?:src|srcset)\s*=\s*|<link\b[^>]*\bhref\s*=\s*|url\(\s*|@import\s+)["']?\s*(?:https?:)?//""", RegexOptions.IgnoreCase)]
|
[GeneratedRegex("""(?:\b(?:src|srcset)\s*=\s*|<link\b[^>]*\bhref\s*=\s*|url\(\s*|@import\s+)["']?\s*(?:https?:)?//""", RegexOptions.IgnoreCase)]
|
||||||
private static partial Regex ForeignResource();
|
private static partial Regex ForeignResource();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Matches the page's <c><footer></c> element, from its opening tag to the
|
||||||
|
/// closing one. The layout renders exactly one footer and nests no other.
|
||||||
|
/// </summary>
|
||||||
|
[GeneratedRegex("""<footer\b.*?</footer>""", RegexOptions.Singleline)]
|
||||||
|
private static partial Regex FooterElement();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Matches an <c>href</c> attribute and captures its value.
|
||||||
|
/// </summary>
|
||||||
|
[GeneratedRegex(@"\bhref=""([^""]*)""")]
|
||||||
|
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();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Matches the "Nächster Termin" card in the home hero. It holds no other link,
|
||||||
|
/// so the first closing <c></a></c> ends it.
|
||||||
|
/// </summary>
|
||||||
|
[GeneratedRegex("""<a class="next-event".*?</a>""", RegexOptions.Singleline)]
|
||||||
|
private static partial Regex NextEventCard();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Matches the first text hidden for sighted visitors but read by screen readers.
|
||||||
|
/// </summary>
|
||||||
|
[GeneratedRegex("""<span class="visually-hidden".*?</span>""", RegexOptions.Singleline)]
|
||||||
|
private static partial Regex VisuallyHidden();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Matches the first list of event cards, from its opening tag to the closing one.
|
||||||
|
/// The cards nest no other <c><ul></c>.
|
||||||
|
/// </summary>
|
||||||
|
[GeneratedRegex("""<ul class="event-list".*?</ul>""", RegexOptions.Singleline)]
|
||||||
|
private static partial Regex EventCards();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Matches the folded-up past events on the events page.
|
||||||
|
/// </summary>
|
||||||
|
[GeneratedRegex("""<details class="past-events[ "].*?</details>""", RegexOptions.Singleline)]
|
||||||
|
private static partial Regex PastEvents();
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
using Elternbeirat.Web.Shared;
|
||||||
|
using Markdig.Syntax;
|
||||||
|
|
||||||
|
namespace Elternbeirat.Web.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Unit tests for the person cards of the "::: team" block: initials, avatar colour,
|
||||||
|
/// reading the list-item convention and the rendered card. Pure logic, no PocketBase
|
||||||
|
/// needed.
|
||||||
|
/// </summary>
|
||||||
|
public class TeamMemberTests
|
||||||
|
{
|
||||||
|
[Theory]
|
||||||
|
[InlineData("Jenny Reger-Stilgenbauer", "JR")] // a double name is one word
|
||||||
|
[InlineData("Özlem Ünal", "ÖÜ")] // umlauts kept, not folded to O/U
|
||||||
|
[InlineData("ömer şahin", "ÖŞ")] // lower case is raised
|
||||||
|
[InlineData("Isabell", "I")] // a single word gives one letter
|
||||||
|
[InlineData("Anna Maria Müller", "AM")] // first and last word only
|
||||||
|
[InlineData(" Maik Palm ", "MP")] // extra whitespace ignored
|
||||||
|
[InlineData("", "")]
|
||||||
|
[InlineData("123", "")] // no letter, no initials
|
||||||
|
public void Initials_are_first_and_last_word(string name, string expected) =>
|
||||||
|
TeamMember.InitialsOf(name).ShouldBe(expected);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Color_is_stable_and_in_range()
|
||||||
|
{
|
||||||
|
// Pinned values: the colour must not change between restarts or releases, so a
|
||||||
|
// change to the hash shows up here instead of recolouring the board.
|
||||||
|
TeamMember.ColorOf("Maik Palm").ShouldBe(TeamMember.ColorOf(" Maik Palm "));
|
||||||
|
TeamMember.ColorOf("Maik Palm").ShouldBe(2);
|
||||||
|
TeamMember.ColorOf("Gentiana Erol").ShouldBe(2);
|
||||||
|
TeamMember.ColorOf("Raul Chira").ShouldBe(1);
|
||||||
|
|
||||||
|
new[] { "Maik Palm", "Gentiana Erol", "Raul Chira", "Marion Leininger", "Isabell Vesper", "" }
|
||||||
|
.ShouldAllBe(name => TeamMember.ColorOf(name) >= 1 && TeamMember.ColorOf(name) <= TeamMember.ColorCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Board_uses_both_colours_about_evenly()
|
||||||
|
{
|
||||||
|
// Two colours only make sense if both show up; the real board (ten names)
|
||||||
|
// must not land mostly on one.
|
||||||
|
string[] board =
|
||||||
|
[
|
||||||
|
"Maik Palm", "Gentiana Erol", "Raul Chira", "Marion Leininger", "Jenny Reger-Stilgenbauer",
|
||||||
|
"Mariella Ianello", "Isabell Vesper", "Susanne Naumburg", "Rebecca Goldmann", "Michael Weber",
|
||||||
|
];
|
||||||
|
|
||||||
|
board.Count(name => TeamMember.ColorOf(name) == 1).ShouldBeInRange(4, 6);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Item_with_role_duties_and_note_is_read_in_full()
|
||||||
|
{
|
||||||
|
var (member, note) = Read("- **Maik Palm** (Vorsitzender) \n *Schulkonferenz, Mensarat , Homepage* \n Mitglied im Schulbeirat.");
|
||||||
|
|
||||||
|
member.Name.ShouldBe("Maik Palm");
|
||||||
|
member.Role.ShouldBe("Vorsitzender");
|
||||||
|
member.Duties.ShouldBe(["Schulkonferenz", "Mensarat", "Homepage"]);
|
||||||
|
Markdown.PlainText(note).ShouldBe("Mitglied im Schulbeirat.");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Item_without_role_still_reads_the_duties()
|
||||||
|
{
|
||||||
|
var (member, note) = Read("- **Jenny Reger-Stilgenbauer** \n *Schulkonferenz, Schaukasten*");
|
||||||
|
|
||||||
|
member.Role.ShouldBeNull();
|
||||||
|
member.Duties.ShouldBe(["Schulkonferenz", "Schaukasten"]);
|
||||||
|
note.ShouldBeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Item_with_only_a_name_has_no_role_and_no_duties()
|
||||||
|
{
|
||||||
|
var (member, note) = Read("- **Isabell Vesper**");
|
||||||
|
|
||||||
|
member.Name.ShouldBe("Isabell Vesper");
|
||||||
|
member.Role.ShouldBeNull();
|
||||||
|
member.Duties.ShouldBeEmpty();
|
||||||
|
note.ShouldBeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Soft_line_breaks_count_like_hard_ones()
|
||||||
|
{
|
||||||
|
// Editors forget the two trailing spaces; the card must not depend on them.
|
||||||
|
var (member, _) = Read("- **Raul Chira** (2. Stellvertreter)\n *Mensarat*");
|
||||||
|
|
||||||
|
member.Role.ShouldBe("2. Stellvertreter");
|
||||||
|
member.Duties.ShouldBe(["Mensarat"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Second_line_that_is_not_only_italic_becomes_the_note()
|
||||||
|
{
|
||||||
|
var (member, note) = Read("- **Rebecca Goldmann** \n Neu im Team, *herzlich willkommen*.");
|
||||||
|
|
||||||
|
member.Duties.ShouldBeEmpty();
|
||||||
|
Markdown.PlainText(note).ShouldBe("Neu im Team, herzlich willkommen.");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("- Michael Weber, Beisitz")] // no bold at all
|
||||||
|
[InlineData("- Beisitz: **Michael Weber**")] // bold, but not at the start
|
||||||
|
[InlineData("- *Michael Weber*")] // italic, not bold
|
||||||
|
public void Item_without_convention_is_not_read(string markdown) =>
|
||||||
|
TeamMember.Read(FirstParagraph(markdown)).ShouldBeNull();
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Team_block_renders_person_cards()
|
||||||
|
{
|
||||||
|
var html = Markdown.ToHtml(
|
||||||
|
"::: team\n- **Maik Palm** (Vorsitzender) \n *Schulkonferenz, Mensarat* \n Siehe [Kontakt](/contact).\n:::");
|
||||||
|
|
||||||
|
html.ShouldContain("<div class=\"team\">");
|
||||||
|
html.ShouldContain($"<li class=\"{TeamMember.CardClass}\">");
|
||||||
|
html.ShouldContain("<span class=\"team-avatar team-avatar-2\" aria-hidden=\"true\">MP</span>");
|
||||||
|
html.ShouldContain("<strong class=\"team-name\">Maik Palm</strong>");
|
||||||
|
html.ShouldContain("<span class=\"team-role\">Vorsitzender</span>");
|
||||||
|
html.ShouldContain("<span class=\"team-duty\">Schulkonferenz</span>");
|
||||||
|
html.ShouldContain("<span class=\"team-duty\">Mensarat</span>");
|
||||||
|
html.ShouldContain("<span class=\"team-note\">Siehe <a href=\"/contact\">Kontakt</a>.</span>"); // links survive
|
||||||
|
html.ShouldNotContain("(Vorsitzender)");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Item_without_convention_renders_as_plain_card_with_its_text()
|
||||||
|
{
|
||||||
|
var html = Markdown.ToHtml("::: team\n- Michael Weber, Beisitz\n:::");
|
||||||
|
|
||||||
|
html.ShouldContain($"<li class=\"{TeamMember.CardClass} {TeamMember.PlainCardClass}\">Michael Weber, Beisitz</li>");
|
||||||
|
html.ShouldNotContain("team-avatar");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Editor_text_in_a_card_is_escaped()
|
||||||
|
{
|
||||||
|
var html = Markdown.ToHtml("::: team\n- **<b>Name</b>** (Rolle \"x\")\n:::");
|
||||||
|
|
||||||
|
html.ShouldNotContain("<b>");
|
||||||
|
html.ShouldContain("<b>Name</b>");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("::: team gross")]
|
||||||
|
[InlineData("::: team groß")]
|
||||||
|
[InlineData("::: team GROSS")]
|
||||||
|
public void Large_modifier_adds_its_class(string opening) =>
|
||||||
|
Markdown.ToHtml($"{opening}\n- **Maik Palm**\n:::").ShouldContain("<div class=\"team gross\">");
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Mistyped_modifier_keeps_the_grid()
|
||||||
|
{
|
||||||
|
// A typo in the modifier costs the highlight, not the cards.
|
||||||
|
var html = Markdown.ToHtml("::: team grss\n- **Maik Palm**\n:::");
|
||||||
|
|
||||||
|
html.ShouldContain("<div class=\"team\">");
|
||||||
|
html.ShouldContain("team-card");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Lists_outside_a_team_block_are_untouched()
|
||||||
|
{
|
||||||
|
var html = Markdown.ToHtml("- **Maik Palm** (Vorsitzender)");
|
||||||
|
|
||||||
|
html.ShouldBe("<ul>\n<li><strong>Maik Palm</strong> (Vorsitzender)</li>\n</ul>\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static (TeamMember Member, Markdig.Syntax.Inlines.Inline[] Note) Read(string markdown) =>
|
||||||
|
TeamMember.Read(FirstParagraph(markdown)) ?? throw new ShouldAssertException("The item was not read as a person.");
|
||||||
|
|
||||||
|
private static ParagraphBlock FirstParagraph(string markdown) =>
|
||||||
|
Markdown.Parse(markdown).Descendants<ParagraphBlock>().First();
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,20 +6,44 @@
|
|||||||
immediately), it pins to the top and stays there while the page scrolls. The
|
immediately), it pins to the top and stays there while the page scrolls. The
|
||||||
footer is kept at the bottom on short pages by a min-height on <main>, not by a
|
footer is kept at the bottom on short pages by a min-height on <main>, not by a
|
||||||
flex wrapper -- see MainLayout.razor.css. *@
|
flex wrapper -- see MainLayout.razor.css. *@
|
||||||
|
|
||||||
|
@* Skip link: the first stop for the Tab key, straight past the menu to the content.
|
||||||
|
The href carries the current path because of <base href="/"> in App.razor: a
|
||||||
|
bare "#main" would resolve to "/#main" and lead every subpage back home.
|
||||||
|
data-enhance-nav="false" leaves the jump to the browser: blazor.web.js would
|
||||||
|
otherwise take the click, only scroll to <main> and keep the focus on the link,
|
||||||
|
so the next Tab would land in the header menu again. *@
|
||||||
|
<a class="skip-link" href="@SkipLinkHref" data-enhance-nav="false">Zum Inhalt springen</a>
|
||||||
|
|
||||||
<header class="site-header">
|
<header class="site-header">
|
||||||
<div class="header-inner">
|
<div class="header-inner">
|
||||||
<a class="brand" href="/">Elternbeirat IGMH</a>
|
@* Two-line lettering next to the signet: the body's name large, the
|
||||||
|
school small below it. One link, so the whole mark leads home. *@
|
||||||
|
<a class="brand" href="/">
|
||||||
|
<Signet />
|
||||||
|
<span class="brand-text">
|
||||||
|
<span class="brand-name">Elternbeirat</span>
|
||||||
|
<span class="brand-school">IGMH Mannheim</span>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
@* CSS-only mobile menu: the hidden checkbox holds the open/closed
|
@* CSS-only mobile menu: the checkbox holds the open/closed state, the
|
||||||
state, the label is the hamburger button, and :checked reveals the
|
label is the hamburger button, and :checked reveals the nav below (see
|
||||||
nav below (see MainLayout.razor.css). No JavaScript, no @@rendermode
|
MainLayout.razor.css). No JavaScript, no @@rendermode -- works under
|
||||||
-- works under static SSR. *@
|
static SSR. The checkbox is only visually hidden on a phone, not
|
||||||
<input type="checkbox" id="nav-toggle" class="nav-toggle" />
|
removed, so it is what keyboard and screen-reader users operate: Tab
|
||||||
<label for="nav-toggle" class="nav-toggle-label" aria-label="Menü öffnen und schließen">
|
reaches it, Space toggles it, and its checked state is the menu's
|
||||||
|
open/closed state, announced as such ("Hauptmenü anzeigen,
|
||||||
|
Kontrollkästchen, aktiviert"). Its name is the label's hidden text;
|
||||||
|
aria-controls ties it to the menu it opens. aria-expanded would need
|
||||||
|
JavaScript to keep it in sync, the native checked state does not. *@
|
||||||
|
<input type="checkbox" id="nav-toggle" class="nav-toggle visually-hidden" aria-controls="main-nav" />
|
||||||
|
<label for="nav-toggle" class="nav-toggle-label">
|
||||||
<span class="nav-toggle-bar"></span>
|
<span class="nav-toggle-bar"></span>
|
||||||
|
<span class="visually-hidden">Hauptmenü anzeigen</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<nav class="main-nav">
|
<nav id="main-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
|
||||||
@@ -32,18 +56,55 @@
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main class="content">
|
@* The skip link's target. tabindex="-1" makes it focusable by the link alone
|
||||||
|
(not by Tab), so after the jump the next Tab continues inside the content. *@
|
||||||
|
<main id="main" class="content" tabindex="-1">
|
||||||
@Body
|
@Body
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<footer class="site-footer">
|
<footer class="site-footer">
|
||||||
|
@* A soft wave as the transition from the page into the dark footer. Pure
|
||||||
|
decoration, so hidden from screen readers; it is filled with the footer
|
||||||
|
colour and stretched to the full width (preserveAspectRatio="none"). *@
|
||||||
|
<svg class="footer-wave" viewBox="0 0 1440 48" preserveAspectRatio="none" aria-hidden="true" focusable="false">
|
||||||
|
<path d="M0 24C240 48 480 48 720 28S1200 0 1440 20V48H0z" />
|
||||||
|
</svg>
|
||||||
|
|
||||||
<div class="footer-inner">
|
<div class="footer-inner">
|
||||||
|
<div class="footer-brand">
|
||||||
|
<Signet />
|
||||||
<span>Elternbeirat der IGMH</span>
|
<span>Elternbeirat der IGMH</span>
|
||||||
<nav class="footer-nav">
|
</div>
|
||||||
|
|
||||||
|
@* Both link columns come from PocketBase like the header. An empty list
|
||||||
|
(no such pages, or PocketBase down) renders nothing -- no lone
|
||||||
|
heading above an empty column. *@
|
||||||
|
@if (_header.Count > 0)
|
||||||
|
{
|
||||||
|
<nav class="footer-nav" aria-label="Schnellzugriff">
|
||||||
|
<h2 class="footer-heading">Schnellzugriff</h2>
|
||||||
|
<ul>
|
||||||
|
@foreach (var link in _header)
|
||||||
|
{
|
||||||
|
<li><a href="@link.Href">@link.Title</a></li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (_footer.Count > 0)
|
||||||
|
{
|
||||||
|
<nav class="footer-nav" aria-label="Weiteres">
|
||||||
|
<h2 class="footer-heading">Weiteres</h2>
|
||||||
|
<ul>
|
||||||
@foreach (var link in _footer)
|
@foreach (var link in _footer)
|
||||||
{
|
{
|
||||||
<a href="@link.Href">@link.Title</a>
|
<li><a href="@link.Href">@link.Title</a></li>
|
||||||
}
|
}
|
||||||
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<p class="footer-copyright">© @_year Elternbeirat der IGMH</p>
|
||||||
</footer>
|
</footer>
|
||||||
@@ -23,6 +23,15 @@ public partial class MainLayout
|
|||||||
private IReadOnlyList<NavLinkItem> _header = [];
|
private IReadOnlyList<NavLinkItem> _header = [];
|
||||||
private IReadOnlyList<NavLinkItem> _footer = [];
|
private IReadOnlyList<NavLinkItem> _footer = [];
|
||||||
|
|
||||||
|
// The skip link's target on this very page: path and query of the request plus
|
||||||
|
// the fragment. See the comment on the skip link in the markup for why it cannot
|
||||||
|
// be a bare "#main".
|
||||||
|
private string SkipLinkHref =>
|
||||||
|
$"{HttpContext?.Request.PathBase}{HttpContext?.Request.Path}{HttpContext?.Request.QueryString}#main";
|
||||||
|
|
||||||
|
// Berlin's year, not the server's (UTC) -- see FooterYear.
|
||||||
|
private readonly int _year = FooterYear.For(TimeProvider.System.GetUtcNow());
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
protected override async Task OnInitializedAsync()
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
|
|||||||
@@ -25,11 +25,12 @@
|
|||||||
z-index keeps it above the content; the opaque background stops content showing
|
z-index keeps it above the content; the opaque background stops content showing
|
||||||
through as it scrolls underneath. */
|
through as it scrolls underneath. */
|
||||||
.site-header {
|
.site-header {
|
||||||
/* Brand-blue bar with a warm accent underline -- the strongest, most visible
|
/* The brand gradient, blue into green -- the strongest, most visible cue that
|
||||||
cue that carries the friendly tone across every page. Uses its own header
|
carries the school's colours across every page. Built on --color-header, so
|
||||||
token so it stays deep blue in dark mode too (see app.css). */
|
it stays deep in dark mode too (see app.css). No accent underline any more:
|
||||||
background: var(--color-header);
|
the gradient already ends in green, and the active menu item carries the
|
||||||
border-bottom: 3px solid var(--color-accent);
|
green stroke. */
|
||||||
|
background: var(--gradient-brand);
|
||||||
box-shadow: var(--shadow);
|
box-shadow: var(--shadow);
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: 0;
|
||||||
@@ -42,26 +43,69 @@
|
|||||||
padding: var(--space-3) 1.25rem;
|
padding: var(--space-3) 1.25rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
align-items: baseline;
|
align-items: center;
|
||||||
gap: var(--space-2) var(--space-4);
|
gap: var(--space-2) var(--space-4);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Signet and two-line lettering side by side. The lettering is a column of its
|
||||||
|
own, so the name and the school line up on the left edge. */
|
||||||
.brand {
|
.brand {
|
||||||
font-size: 1.35rem;
|
display: flex;
|
||||||
font-weight: 700;
|
align-items: center;
|
||||||
color: #fff;
|
gap: var(--space-2);
|
||||||
|
color: var(--color-on-header);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.brand:hover {
|
||||||
|
color: var(--color-on-header);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* On the blue gradient the global brand-blue focus ring would vanish; white
|
||||||
|
stays visible on both ends of it. */
|
||||||
|
.brand:focus-visible {
|
||||||
|
outline-color: var(--color-focus-inverse);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The signet lives in its own component (Signet.razor), so its <svg> carries that
|
||||||
|
component's scope attribute, not this one's; ::deep reaches it from here. */
|
||||||
|
.brand ::deep .signet {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 2.5rem;
|
||||||
|
height: 2.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-text {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
line-height: 1.15;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-name {
|
||||||
|
font-size: 1.35rem;
|
||||||
|
font-weight: var(--font-weight-heading);
|
||||||
|
letter-spacing: var(--letter-spacing-heading);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-school {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The links are pills with their own padding (see app.css), so the gap between
|
||||||
|
them can stay small. */
|
||||||
.main-nav {
|
.main-nav {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 0.25rem 1.25rem;
|
align-items: center;
|
||||||
|
gap: var(--space-1) var(--space-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The hamburger button and its state checkbox are hidden on wide screens; the
|
/* The hamburger button and its state checkbox are hidden on wide screens; the
|
||||||
media query below turns them on for narrow ones. */
|
media query below turns them on for narrow ones. display:none (beating the
|
||||||
|
global .visually-hidden the checkbox also carries) takes the checkbox out of the
|
||||||
|
tab order here, where there is no menu to open. */
|
||||||
.nav-toggle,
|
.nav-toggle,
|
||||||
.nav-toggle-label {
|
.nav-toggle-label {
|
||||||
display: none;
|
display: none;
|
||||||
@@ -72,11 +116,12 @@
|
|||||||
(b-xxxxx), so a scoped rule ".main-nav a" would not match them. See app.css. */
|
(b-xxxxx), so a scoped rule ".main-nav a" would not match them. See app.css. */
|
||||||
|
|
||||||
/* Content: at least tall enough that the footer sits at the bottom of the viewport
|
/* Content: at least tall enough that the footer sits at the bottom of the viewport
|
||||||
on short pages. 100dvh - 4rem leaves room for the header (which sits above in
|
on short pages. 100dvh - 5rem leaves room for the header with its signet (which
|
||||||
normal flow); dvh follows the mobile browser's shrinking toolbar. On long pages
|
sits above in normal flow); dvh follows the mobile browser's shrinking toolbar.
|
||||||
the content simply grows past this and pushes the footer down naturally. */
|
On long pages the content simply grows past this and pushes the footer down
|
||||||
|
naturally. */
|
||||||
.content {
|
.content {
|
||||||
min-height: calc(100dvh - 4rem);
|
min-height: calc(100dvh - 5rem);
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: var(--content-width);
|
max-width: var(--content-width);
|
||||||
@@ -84,39 +129,106 @@
|
|||||||
padding: var(--space-4) 1.25rem var(--space-6);
|
padding: var(--space-4) 1.25rem var(--space-6);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Footer */
|
/* Footer: a dark brand-blue band with light text, so the page visibly ends here.
|
||||||
|
Its own tokens (--color-footer, --color-on-footer...) keep it dark in both
|
||||||
|
colour schemes, like the header. position:relative anchors the wave. */
|
||||||
.site-footer {
|
.site-footer {
|
||||||
border-top: 1px solid var(--color-border);
|
position: relative;
|
||||||
background: var(--color-surface);
|
margin-top: var(--space-6);
|
||||||
color: var(--color-text-soft);
|
background: var(--color-footer);
|
||||||
font-size: 0.9rem;
|
color: var(--color-on-footer-soft);
|
||||||
|
font-size: 0.95rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The wave sits just above the footer's top edge (bottom: 100%), drawn over the
|
||||||
|
page background in the footer colour, so the band seems to rise softly instead
|
||||||
|
of starting with a hard line. Fixed height, full width: on a phone it simply
|
||||||
|
flattens out. Overlapped by 1px so no hairline gap shows between wave and band
|
||||||
|
when the browser rounds. */
|
||||||
|
.footer-wave {
|
||||||
|
position: absolute;
|
||||||
|
bottom: calc(100% - 1px);
|
||||||
|
left: 0;
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 2.5rem;
|
||||||
|
fill: var(--color-footer);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Three columns: brand, quick links, further pages. The brand gets a bit more room
|
||||||
|
because it holds the signet. A missing link column simply leaves its cell empty,
|
||||||
|
so the others keep their place. */
|
||||||
.footer-inner {
|
.footer-inner {
|
||||||
max-width: var(--content-width);
|
max-width: var(--content-width);
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: var(--space-3) 1.25rem;
|
padding: var(--space-5) 1.25rem var(--space-4);
|
||||||
display: flex;
|
display: grid;
|
||||||
flex-wrap: wrap;
|
grid-template-columns: 1.5fr 1fr 1fr;
|
||||||
justify-content: space-between;
|
|
||||||
gap: var(--space-2) var(--space-4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer-nav {
|
|
||||||
display: flex;
|
|
||||||
gap: var(--space-4);
|
gap: var(--space-4);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.footer-brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
align-self: start;
|
||||||
|
gap: var(--space-2);
|
||||||
|
color: var(--color-on-footer);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-brand ::deep .signet {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 3rem;
|
||||||
|
height: 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Column headings label the lists; they are not a second page heading, so they
|
||||||
|
stay small and quiet. */
|
||||||
|
.footer-heading {
|
||||||
|
margin: 0 0 var(--space-2);
|
||||||
|
color: var(--color-on-footer);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-nav ul {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-nav li + li {
|
||||||
|
margin-top: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
.footer-nav a {
|
.footer-nav a {
|
||||||
color: var(--color-text-soft);
|
color: var(--color-on-footer-soft);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.footer-nav a:hover {
|
.footer-nav a:hover {
|
||||||
color: var(--color-brand);
|
color: var(--color-on-footer);
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Same reason as the brand link in the header: a light ring on the dark band. */
|
||||||
|
.footer-nav a:focus-visible {
|
||||||
|
outline-color: var(--color-focus-inverse);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Copyright: a thin rule separates it from the columns. The rule spans the content
|
||||||
|
width, not the whole window, so it lines up with the columns above. */
|
||||||
|
.footer-copyright {
|
||||||
|
box-sizing: border-box;
|
||||||
|
max-width: var(--content-width);
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: var(--space-3) 1.25rem;
|
||||||
|
border-top: 1px solid var(--color-footer-border);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
/* Mobile: on narrow screens the nav collapses behind a hamburger button. This is
|
/* Mobile: on narrow screens the nav collapses behind a hamburger button. This is
|
||||||
pure CSS -- the #nav-toggle checkbox holds the open/closed state and its
|
pure CSS -- the #nav-toggle checkbox holds the open/closed state and its
|
||||||
:checked state reveals the nav. No JavaScript, so it works under static SSR. */
|
:checked state reveals the nav. No JavaScript, so it works under static SSR. */
|
||||||
@@ -129,6 +241,19 @@
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The checkbox comes back as a real, focusable control -- only visually hidden
|
||||||
|
(global .visually-hidden), so Tab stops on it and Space toggles the menu. It
|
||||||
|
lives under the label, so the focus ring is drawn there instead. */
|
||||||
|
.nav-toggle {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-toggle:focus-visible + .nav-toggle-label {
|
||||||
|
outline: 3px solid var(--color-focus-inverse);
|
||||||
|
outline-offset: 2px;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
}
|
||||||
|
|
||||||
/* Show the hamburger button. */
|
/* Show the hamburger button. */
|
||||||
.nav-toggle-label {
|
.nav-toggle-label {
|
||||||
display: block;
|
display: block;
|
||||||
@@ -144,8 +269,9 @@
|
|||||||
display: block;
|
display: block;
|
||||||
width: 1.5rem;
|
width: 1.5rem;
|
||||||
height: 2px;
|
height: 2px;
|
||||||
background: #fff; /* on the blue header bar */
|
background: var(--color-on-header);
|
||||||
content: "";
|
content: "";
|
||||||
|
transition: transform var(--transition), background-color var(--transition);
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-toggle-bar::before {
|
.nav-toggle-bar::before {
|
||||||
@@ -156,25 +282,54 @@
|
|||||||
transform: translateY(4px); /* 6px minus the bar's own 2px height */
|
transform: translateY(4px); /* 6px minus the bar's own 2px height */
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The nav takes its own row and is hidden until the checkbox is checked. */
|
/* Open: the hamburger turns into an X. The middle bar fades out and the outer
|
||||||
|
two meet in the middle and cross. Only the icon changes shape; nothing around
|
||||||
|
it moves. The checkbox sits right before the label, hence the + combinator. */
|
||||||
|
.nav-toggle:checked + .nav-toggle-label .nav-toggle-bar {
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-toggle:checked + .nav-toggle-label .nav-toggle-bar::before {
|
||||||
|
transform: rotate(45deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-toggle:checked + .nav-toggle-label .nav-toggle-bar::after {
|
||||||
|
transform: translateY(-2px) rotate(-45deg); /* back up by its own height */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The nav takes its own row and is hidden until the checkbox is checked.
|
||||||
|
max-height:0 alone would only hide it from the eye: its links would still
|
||||||
|
be Tab stops and still be read out. visibility:hidden takes them out of
|
||||||
|
both. Transitioning visibility delays the switch to hidden until the fold
|
||||||
|
has finished, so closing still animates. Opening uses the transition of the
|
||||||
|
:checked rule below, which leaves visibility out: the links must be Tab
|
||||||
|
stops the moment Space is pressed, not 0.2s later. */
|
||||||
.main-nav {
|
.main-nav {
|
||||||
flex-basis: 100%;
|
flex-basis: 100%;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0;
|
gap: 0;
|
||||||
max-height: 0;
|
max-height: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
transition: max-height 0.2s ease;
|
visibility: hidden;
|
||||||
|
transition: max-height 0.2s ease, visibility 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Checked state: reveal the stacked links. The max-height caps the open menu
|
/* Checked state: reveal the stacked links. The max-height caps the open menu
|
||||||
to the space below the sticky header; a longer list scrolls inside it
|
to the space below the sticky header; a longer list scrolls inside it
|
||||||
instead of running off the screen. */
|
instead of running off the screen. */
|
||||||
.nav-toggle:checked ~ .main-nav {
|
.nav-toggle:checked ~ .main-nav {
|
||||||
|
visibility: visible;
|
||||||
|
transition: max-height 0.2s ease;
|
||||||
max-height: 70vh;
|
max-height: 70vh;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
margin-top: 0.5rem;
|
margin-top: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Mobile per-link spacing/separators for .main-nav a are in the global app.css
|
/* Mobile per-link spacing for .main-nav a is in the global app.css too, for
|
||||||
too, for the same scoped-attribute reason as above. */
|
the same scoped-attribute reason as above. */
|
||||||
|
|
||||||
|
/* The footer columns stack: one column, brand on top. */
|
||||||
|
.footer-inner {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -6,16 +6,8 @@
|
|||||||
<ul class="event-list">
|
<ul class="event-list">
|
||||||
@foreach (var ev in _upcomingEvents)
|
@foreach (var ev in _upcomingEvents)
|
||||||
{
|
{
|
||||||
<li>
|
@* A teaser: each card leads to the events page with the full list. *@
|
||||||
<a class="card-link" href="/events">
|
<li><EventCard Value="ev" Href="/events" /></li>
|
||||||
<span class="event-title">@ev.Title</span>
|
|
||||||
<EventDate Value="ev" />
|
|
||||||
@if (!string.IsNullOrWhiteSpace(ev.Location))
|
|
||||||
{
|
|
||||||
<span class="event-location">@ev.Location</span>
|
|
||||||
}
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
}
|
}
|
||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -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)
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
/* Card styles for .event-list are shared with the full events page and live in the
|
/* The card is EventCard and the list grid lives in the global app.css, shared with
|
||||||
global app.css. Only the embed's spacing on the home page is specific here. */
|
the full events page. Only the embed's spacing on the home page is specific here. */
|
||||||
.embed-events {
|
.embed-events {
|
||||||
margin-top: var(--space-5);
|
margin-top: var(--space-5);
|
||||||
}
|
}
|
||||||
@@ -6,12 +6,7 @@
|
|||||||
<ul class="post-list">
|
<ul class="post-list">
|
||||||
@foreach (var post in _latest)
|
@foreach (var post in _latest)
|
||||||
{
|
{
|
||||||
<li>
|
<li><PostCard Value="post" /></li>
|
||||||
<a class="card-link" href="@($"/posts/{post.Slug}")">
|
|
||||||
<span class="post-title">@post.Title</span>
|
|
||||||
<PostDate Value="post.Date" />
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
}
|
}
|
||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/* Card styles for .post-list are shared with the full posts page and live in the
|
/* The card is PostCard and the list grid lives in the global app.css, shared with
|
||||||
global app.css. Only the embed's spacing on the home page is specific here. */
|
the full posts page. Only the embed's spacing on the home page is specific here. */
|
||||||
.embed-posts {
|
.embed-posts {
|
||||||
margin-top: var(--space-5);
|
margin-top: var(--space-5);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
@*
|
||||||
|
A tear-off calendar sheet for an event's start: short month on a green band,
|
||||||
|
the day large, the weekday small. Pure decoration, hidden from screen readers:
|
||||||
|
every caller puts the full date (<EventDate>) next to it.
|
||||||
|
*@
|
||||||
|
<span class="calendar-sheet" aria-hidden="true">
|
||||||
|
<span class="calendar-month">@EventDateFormat.SheetMonth(Value)</span>
|
||||||
|
<span class="calendar-day">@EventDateFormat.SheetDay(Value)</span>
|
||||||
|
<span class="calendar-weekday">@EventDateFormat.SheetWeekday(Value)</span>
|
||||||
|
</span>
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
using Microsoft.AspNetCore.Components;
|
||||||
|
|
||||||
|
namespace Elternbeirat.Web.Features.Events;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Renders an event's start day as a calendar sheet (month, day, weekday).
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// One component for every place that shows an event (the next-event card on the
|
||||||
|
/// home page, the events embed and the events page), so the sheets look the same
|
||||||
|
/// everywhere. The texts come from <see cref="EventDateFormat"/>.
|
||||||
|
/// </remarks>
|
||||||
|
/// <seealso cref="EventCard"/>
|
||||||
|
public partial class CalendarSheet
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The event's start, as Berlin wall-clock time.
|
||||||
|
/// </summary>
|
||||||
|
[Parameter]
|
||||||
|
[EditorRequired]
|
||||||
|
public DateTime Value { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
/* The calendar sheet: a green band with the month over a large day number and the
|
||||||
|
weekday. Fixed width, so the sheets of a list line up whatever the month. The band
|
||||||
|
has its own token (--color-calendar-band): the plain green accent is too light for
|
||||||
|
small white text, the band's darker green keeps it above AA in both schemes.
|
||||||
|
Positioning (gap to the text beside it) is the caller's job. */
|
||||||
|
.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;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-month {
|
||||||
|
padding: 0.15rem 0;
|
||||||
|
background: var(--color-calendar-band);
|
||||||
|
color: var(--color-on-button);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-day {
|
||||||
|
padding-top: var(--space-1);
|
||||||
|
color: var(--color-heading);
|
||||||
|
font-size: 1.75rem;
|
||||||
|
font-weight: var(--font-weight-heading);
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-weekday {
|
||||||
|
padding-bottom: var(--space-1);
|
||||||
|
color: var(--color-text-soft);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
@*
|
||||||
|
One event as a card: calendar sheet on the left, title, time, place and note on
|
||||||
|
the right. With an Href the whole card is that link (home embed); without one it
|
||||||
|
is a plain card (events page) and offers the event as a calendar file instead.
|
||||||
|
*@
|
||||||
|
@if (Href is null)
|
||||||
|
{
|
||||||
|
<div class="event-card">@Details</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<a class="event-card event-card-link" href="@Href">@Details</a>
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
// The card's content, the same in both forms. A template, not a child component,
|
||||||
|
// so the wrapper above stays the only difference between the two.
|
||||||
|
private RenderFragment Details => @<text>
|
||||||
|
<CalendarSheet Value="Value.Start" />
|
||||||
|
<span class="event-details">
|
||||||
|
<span class="event-title">@Value.Title</span>
|
||||||
|
@* The sheet is hidden from screen readers; this is the date they hear. *@
|
||||||
|
<span class="visually-hidden"><EventDate Value="Value" /></span>
|
||||||
|
@if (Span.Length > 0)
|
||||||
|
{
|
||||||
|
<span class="event-meta"><Icon Name="clock" Size="16" /> @Span</span>
|
||||||
|
}
|
||||||
|
@if (!string.IsNullOrWhiteSpace(Value.Location))
|
||||||
|
{
|
||||||
|
<span class="event-meta"><Icon Name="map-pin" Size="16" /> @Value.Location</span>
|
||||||
|
}
|
||||||
|
@if (!string.IsNullOrWhiteSpace(Value.Note))
|
||||||
|
{
|
||||||
|
<span class="event-note">@Value.Note</span>
|
||||||
|
}
|
||||||
|
@* A link inside a link is invalid HTML, so only the plain card offers it. *@
|
||||||
|
@if (Href is null)
|
||||||
|
{
|
||||||
|
<a class="event-ics" href="@IcsHref"><Icon Name="calendar-plus" Size="16" /> In Kalender übernehmen</a>
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
</text>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
using Elternbeirat.Contracts;
|
||||||
|
using Microsoft.AspNetCore.Components;
|
||||||
|
|
||||||
|
namespace Elternbeirat.Web.Features.Events;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Renders one event as a card with a calendar sheet, time, place and note.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Shared by the events page and the home-page events embed, so both show events
|
||||||
|
/// the same way. They differ only in what the card leads to:
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item>
|
||||||
|
/// <description>
|
||||||
|
/// In the embed the whole card links to the events page
|
||||||
|
/// (<see cref="Href"/> set).
|
||||||
|
/// </description>
|
||||||
|
/// </item>
|
||||||
|
/// <item>
|
||||||
|
/// <description>
|
||||||
|
/// On the events page itself a link back to the same page would lead
|
||||||
|
/// nowhere, so the card is no link (<see cref="Href"/> is
|
||||||
|
/// <see langword="null"/>) and offers "In Kalender übernehmen" instead.
|
||||||
|
/// </description>
|
||||||
|
/// </item>
|
||||||
|
/// </list>
|
||||||
|
/// </remarks>
|
||||||
|
/// <seealso cref="CalendarSheet"/>
|
||||||
|
/// <seealso cref="IcsCalendar"/>
|
||||||
|
public partial class EventCard
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The event to show.
|
||||||
|
/// </summary>
|
||||||
|
[Parameter]
|
||||||
|
[EditorRequired]
|
||||||
|
public Event Value { get; set; } = null!;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Where the card links to, or <see langword="null"/> for a card that is no link
|
||||||
|
/// and offers the event as a calendar file instead.
|
||||||
|
/// </summary>
|
||||||
|
[Parameter]
|
||||||
|
public string? Href { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The time line beside the sheet; empty for a single all-day event.
|
||||||
|
/// </summary>
|
||||||
|
private string Span => EventDateFormat.Span(Value);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The address of this one event as a calendar file.
|
||||||
|
/// </summary>
|
||||||
|
private string IcsHref => $"/events/{Uri.EscapeDataString(Value.Id)}.ics";
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
/* The event card: a white surface on the tinted page, sheet left, text right. The
|
||||||
|
card fills its list cell, so the cards of a row are equally tall. */
|
||||||
|
.event-card {
|
||||||
|
box-sizing: border-box;
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-3);
|
||||||
|
align-items: flex-start;
|
||||||
|
height: 100%;
|
||||||
|
padding: var(--space-3) var(--space-4);
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--color-bg);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Only the linked card reacts to hover: a card that leads nowhere must not look
|
||||||
|
clickable. Hover = stronger shadow and a blue edge, nothing moves. */
|
||||||
|
.event-card-link {
|
||||||
|
text-decoration: none;
|
||||||
|
transition: box-shadow var(--transition), border-color var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-card-link:hover {
|
||||||
|
border-color: var(--color-brand);
|
||||||
|
box-shadow: var(--shadow-hover);
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-details {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-1);
|
||||||
|
min-width: 0; /* lets a long title wrap instead of widening the card */
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-title {
|
||||||
|
color: var(--color-brand);
|
||||||
|
font-size: 1.15rem;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Time and place: icon and text on one quiet line each. The icon belongs to Icon's
|
||||||
|
own scope, so it is reached through ::deep. */
|
||||||
|
.event-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-1);
|
||||||
|
color: var(--color-text-soft);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-meta ::deep .icon {
|
||||||
|
color: var(--color-accent-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-note {
|
||||||
|
margin-top: var(--space-1);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* In the linked card (home embed) the note is only a teaser: cut after two lines,
|
||||||
|
so a long note does not stretch the card next to short ones. The full note is
|
||||||
|
on the events page. */
|
||||||
|
.event-card-link .event-note {
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
line-clamp: 2;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-ics {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-1);
|
||||||
|
align-self: flex-start;
|
||||||
|
margin-top: var(--space-2);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
@@ -7,8 +7,10 @@ namespace Elternbeirat.Web.Features.Events;
|
|||||||
/// Formats the date range of an <see cref="Event"/> for display in German.
|
/// Formats the date range of an <see cref="Event"/> for display in German.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Shared by the full event list and the home-page events embed, so both render
|
/// Shared by the full event list, the home-page events embed and the next-event
|
||||||
/// dates the same way.
|
/// card, so all render dates the same way: <see cref="Range"/> as the full date,
|
||||||
|
/// the <c>Sheet…</c> methods and <see cref="Span"/> for the calendar sheet and the
|
||||||
|
/// time line beside it, <see cref="PastDays"/> and <see cref="PastTime"/> for the list of past events.
|
||||||
/// <para>
|
/// <para>
|
||||||
/// This is the event counterpart to
|
/// This is the event counterpart to
|
||||||
/// <see cref="Posts.PostDateFormat"/>, which formats a plain
|
/// <see cref="Posts.PostDateFormat"/>, which formats a plain
|
||||||
@@ -27,6 +29,11 @@ public static class EventDateFormat
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
private const string ShortDate = "d. MMMM yyyy";
|
private const string ShortDate = "d. MMMM yyyy";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Date format for the list of past events, which is grouped by year.
|
||||||
|
/// </summary>
|
||||||
|
private const string DayMonth = "d. MMMM";
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Formats the date range of an event.
|
/// Formats the date range of an event.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -54,13 +61,136 @@ public static class EventDateFormat
|
|||||||
(var start, null, var hasTime)
|
(var start, null, var hasTime)
|
||||||
=> Stamp(start, LongDate, hasTime),
|
=> Stamp(start, LongDate, hasTime),
|
||||||
(var start, { } end, true) when end.Date == start.Date
|
(var start, { } end, true) when end.Date == start.Date
|
||||||
=> $"{start.ToString(LongDate, Cultures.German)}, {start.ToString("HH:mm", Cultures.German)}–{end.ToString("HH:mm", Cultures.German)} Uhr",
|
=> $"{start.ToString(LongDate, Cultures.German)}, {Clock(start)}–{Clock(end)} Uhr",
|
||||||
(var start, { } end, false) when end.Date == start.Date
|
(var start, { } end, false) when end.Date == start.Date
|
||||||
=> Stamp(start, LongDate, withTime: false),
|
=> Stamp(start, LongDate, withTime: false),
|
||||||
(var start, { } end, var hasTime)
|
(var start, { } end, var hasTime)
|
||||||
=> $"{Stamp(start, LongDate, hasTime)} – {Stamp(end, ShortDate, hasTime)}",
|
=> $"{Stamp(start, LongDate, hasTime)} – {Stamp(end, ShortDate, hasTime)}",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The short month for the top band of a calendar sheet.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="value">The event's start.</param>
|
||||||
|
/// <returns>The German short month without its period, e.g. <c>Okt</c> or <c>März</c>.</returns>
|
||||||
|
/// <remarks>
|
||||||
|
/// Returned in normal case; the sheet's CSS sets it in capitals. The period of the
|
||||||
|
/// abbreviation (<c>Okt.</c>) is dropped because on a sheet it reads as a stray dot.
|
||||||
|
/// </remarks>
|
||||||
|
/// <seealso cref="CalendarSheet"/>
|
||||||
|
public static string SheetMonth(DateTime value)
|
||||||
|
=> value.ToString("MMM", Cultures.German).TrimEnd('.');
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The day of the month for the large number on a calendar sheet.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="value">The event's start.</param>
|
||||||
|
/// <returns>The day without a leading zero, e.g. <c>8</c>.</returns>
|
||||||
|
public static string SheetDay(DateTime value)
|
||||||
|
=> value.Day.ToString(Cultures.German);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The short weekday for the bottom of a calendar sheet.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="value">The event's start.</param>
|
||||||
|
/// <returns>The German short weekday without its period, e.g. <c>Do</c>.</returns>
|
||||||
|
public static string SheetWeekday(DateTime value)
|
||||||
|
=> value.ToString("ddd", Cultures.German).TrimEnd('.');
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The time line beside a calendar sheet: what the sheet itself cannot show.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="event">The event to format.</param>
|
||||||
|
/// <returns>
|
||||||
|
/// Depending on <see cref="Event.End"/> and <see cref="Event.HasTime"/>:
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item><description>No end: <c>19:30 Uhr</c></description></item>
|
||||||
|
/// <item><description>Same day: <c>19:30–21:00 Uhr</c></description></item>
|
||||||
|
/// <item><description>Several days: <c>09:00 Uhr bis Sonntag, 11. Oktober 2026, 13:00 Uhr</c></description></item>
|
||||||
|
/// <item><description>Several days, no time: <c>bis Sonntag, 11. Oktober 2026</c></description></item>
|
||||||
|
/// <item><description>One day, no time: an empty string; the sheet says it all.</description></item>
|
||||||
|
/// </list>
|
||||||
|
/// </returns>
|
||||||
|
/// <remarks>
|
||||||
|
/// The sheet shows the start day only, so a multi-day event must name its last
|
||||||
|
/// day here, with the weekday, as the sheet does for the first one.
|
||||||
|
/// </remarks>
|
||||||
|
public static string Span(Event @event)
|
||||||
|
=> (@event.Start, @event.End, @event.HasTime) switch
|
||||||
|
{
|
||||||
|
(var start, { } end, var hasTime) when end.Date != start.Date
|
||||||
|
=> (hasTime ? Clock(start) + " Uhr " : "") + "bis " + Stamp(end, LongDate, hasTime),
|
||||||
|
(var start, null, true)
|
||||||
|
=> $"{Clock(start)} Uhr",
|
||||||
|
(var start, { } end, true)
|
||||||
|
=> $"{Clock(start)}–{Clock(end)} Uhr",
|
||||||
|
_ => "",
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The day column of a past event in the folded-up list on the events page.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="event">The event to format.</param>
|
||||||
|
/// <returns>
|
||||||
|
/// Day and full month, depending on <see cref="Event.End"/>:
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item><description>One day: <c>13. November</c></description></item>
|
||||||
|
/// <item><description>Several days in one month: <c>8.–9. Februar</c></description></item>
|
||||||
|
/// <item><description>Across a month: <c>30. Januar – 2. Februar</c></description></item>
|
||||||
|
/// <item><description>Across a year: <c>30. Dezember – 2. Januar 2027</c></description></item>
|
||||||
|
/// </list>
|
||||||
|
/// </returns>
|
||||||
|
/// <remarks>
|
||||||
|
/// The list is grouped by the start year under its own small heading, so the
|
||||||
|
/// year is only named where the end leaves that year. The weekday is left out
|
||||||
|
/// to keep the column short; the time follows the title (<see cref="PastTime"/>).
|
||||||
|
/// </remarks>
|
||||||
|
public static string PastDays(Event @event)
|
||||||
|
=> (@event.Start, @event.End) switch
|
||||||
|
{
|
||||||
|
(var start, { } end) when end.Year != start.Year
|
||||||
|
=> $"{start.ToString(DayMonth, Cultures.German)} – {end.ToString(ShortDate, Cultures.German)}",
|
||||||
|
(var start, { } end) when end.Month != start.Month
|
||||||
|
=> $"{start.ToString(DayMonth, Cultures.German)} – {end.ToString(DayMonth, Cultures.German)}",
|
||||||
|
(var start, { } end) when end.Day != start.Day
|
||||||
|
=> $"{start.Day.ToString(Cultures.German)}.–{end.ToString(DayMonth, Cultures.German)}",
|
||||||
|
(var start, _)
|
||||||
|
=> start.ToString(DayMonth, Cultures.German),
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The time after the title of a past event in the folded-up list.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="event">The event to format.</param>
|
||||||
|
/// <returns>
|
||||||
|
/// Depending on <see cref="Event.HasTime"/> and <see cref="Event.End"/>:
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item><description>No time: an empty string; nothing is shown.</description></item>
|
||||||
|
/// <item><description>No end: <c>19:00 Uhr</c></description></item>
|
||||||
|
/// <item><description>Same day: <c>19:00–21:00 Uhr</c></description></item>
|
||||||
|
/// <item><description>Several days: the start time only, <c>09:00 Uhr</c></description></item>
|
||||||
|
/// </list>
|
||||||
|
/// </returns>
|
||||||
|
/// <remarks>
|
||||||
|
/// For several days the day column already shows the span; a time range beside
|
||||||
|
/// it would read as the daily hours, so only the start time is given.
|
||||||
|
/// </remarks>
|
||||||
|
public static string PastTime(Event @event)
|
||||||
|
=> (@event.HasTime, @event.End) switch
|
||||||
|
{
|
||||||
|
(false, _) => "",
|
||||||
|
(true, { } end) when end.Date != @event.Start.Date => $"{Clock(@event.Start)} Uhr",
|
||||||
|
_ => Span(@event),
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Formats the clock time of a point in time.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="value">The date and time.</param>
|
||||||
|
/// <returns>The time as e.g. <c>19:30</c>.</returns>
|
||||||
|
private static string Clock(DateTime value)
|
||||||
|
=> value.ToString("HH:mm", Cultures.German);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Formats a single point in time as a date with an optional time.
|
/// Formats a single point in time as a date with an optional time.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -21,10 +21,9 @@
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<p class="intro">
|
@* The page body above is the intro; the page only adds the subscribe button. *@
|
||||||
Alle Termine des Elternbeirats auf einen Blick. Sie können den Kalender auch
|
<p class="events-subscribe">
|
||||||
<a href="/events.ics">abonnieren</a> und in Ihrer Kalender-App automatisch
|
<a class="btn btn-secondary" href="/events.ics"><Icon Name="calendar-plus" Size="18" /> Kalender abonnieren</a>
|
||||||
aktuell halten.
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h2>Kommende Termine</h2>
|
<h2>Kommende Termine</h2>
|
||||||
@@ -38,41 +37,38 @@ else
|
|||||||
<ul class="event-list">
|
<ul class="event-list">
|
||||||
@foreach (var ev in _upcomingEvents)
|
@foreach (var ev in _upcomingEvents)
|
||||||
{
|
{
|
||||||
<li>
|
@* No Href: a card linking to this very page would lead nowhere. *@
|
||||||
<a class="card-link" href="/events">
|
<li><EventCard Value="ev" /></li>
|
||||||
<span class="event-title">@ev.Title</span>
|
|
||||||
<EventDate Value="ev" />
|
|
||||||
@if (!string.IsNullOrWhiteSpace(ev.Location))
|
|
||||||
{
|
|
||||||
<span class="event-location">@ev.Location</span>
|
|
||||||
}
|
|
||||||
@if (!string.IsNullOrWhiteSpace(ev.Note))
|
|
||||||
{
|
|
||||||
<p class="event-note">@ev.Note</p>
|
|
||||||
}
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
}
|
}
|
||||||
</ul>
|
</ul>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@* Past events matter rarely, so they wait folded up and compact: grouped by year,
|
||||||
|
then day, title and (if the event has one) the time per row. *@
|
||||||
@if (_pastEvents.Count > 0)
|
@if (_pastEvents.Count > 0)
|
||||||
{
|
{
|
||||||
<h2>Vergangene Termine</h2>
|
<details class="past-events disclosure">
|
||||||
<ul class="event-list event-list-past">
|
<summary>Vergangene Termine anzeigen (@_pastEvents.Count)</summary>
|
||||||
@foreach (var ev in _pastEvents)
|
@foreach (var year in PastYears)
|
||||||
{
|
{
|
||||||
|
<h3 class="past-events-year">@year.Key</h3>
|
||||||
|
<ul>
|
||||||
|
@foreach (var ev in year)
|
||||||
|
{
|
||||||
|
var time = EventDateFormat.PastTime(ev);
|
||||||
<li>
|
<li>
|
||||||
<a class="card-link" href="/events">
|
<span class="past-event-date">@EventDateFormat.PastDays(ev)</span>
|
||||||
<span class="event-title">@ev.Title</span>
|
<span class="past-event-title">
|
||||||
<EventDate Value="ev" />
|
@ev.Title
|
||||||
@if (!string.IsNullOrWhiteSpace(ev.Location))
|
@if (time.Length > 0)
|
||||||
{
|
{
|
||||||
<span class="event-location">@ev.Location</span>
|
<span class="past-event-time">@time</span>
|
||||||
}
|
}
|
||||||
</a>
|
</span>
|
||||||
</li>
|
</li>
|
||||||
}
|
}
|
||||||
</ul>
|
</ul>
|
||||||
}
|
}
|
||||||
|
</details>
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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; }
|
||||||
|
|
||||||
@@ -36,6 +39,10 @@ public partial class EventList
|
|||||||
// 503 this sets on the response.
|
// 503 this sets on the response.
|
||||||
private bool _unavailable;
|
private bool _unavailable;
|
||||||
|
|
||||||
|
// Past events grouped by year for the small year headings. _pastEvents is most
|
||||||
|
// recent first, and GroupBy keeps that order, so the newest year comes first.
|
||||||
|
private IEnumerable<IGrouping<int, Event>> PastYears => _pastEvents.GroupBy(@event => @event.Start.Year);
|
||||||
|
|
||||||
// The page body rendered from Markdown to HTML, ready to emit as a MarkupString.
|
// The page body rendered from Markdown to HTML, ready to emit as a MarkupString.
|
||||||
private MarkupString Body => (MarkupString)Markdown.ToHtml(_page?.Body);
|
private MarkupString Body => (MarkupString)Markdown.ToHtml(_page?.Body);
|
||||||
|
|
||||||
@@ -62,13 +69,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
|
||||||
|
|||||||
@@ -1,2 +1,85 @@
|
|||||||
/* The .event-list card styles (including past-event styling) are shared with the
|
/* The card grid (.event-list) is shared with the home-page embed and lives in the
|
||||||
home-page embed and live in the global app.css. Nothing page-specific here. */
|
global app.css; the card itself is EventCard. Only the page's own parts are here. */
|
||||||
|
|
||||||
|
/* Same centred 42rem reading column as a content page (ContentPage.razor.css), so
|
||||||
|
every page of the site shares one column under the header. */
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
article,
|
||||||
|
p,
|
||||||
|
.event-list,
|
||||||
|
.past-events {
|
||||||
|
max-width: 42rem;
|
||||||
|
margin-inline: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* auto, not 0: this rule outweighs the column rule above and would otherwise pull
|
||||||
|
the button back to the left edge of the page. */
|
||||||
|
.events-subscribe {
|
||||||
|
margin: var(--space-4) auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Past events: one folded-up card. Opened, a compact list grouped by year,
|
||||||
|
deliberately quieter than the upcoming cards above. The open/close triangle is
|
||||||
|
the shared .disclosure rule in app.css, the same as on the FAQ topics. */
|
||||||
|
.past-events {
|
||||||
|
box-sizing: border-box; /* padding inside the 42rem, flush with the text */
|
||||||
|
margin-top: var(--space-5);
|
||||||
|
padding: var(--space-3) var(--space-4);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--color-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.past-events-year {
|
||||||
|
margin: var(--space-4) 0 var(--space-1);
|
||||||
|
color: var(--color-text-soft);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.past-events ul {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* On a phone the day sits above the title: a column beside it would leave the
|
||||||
|
title too little room. */
|
||||||
|
.past-events li {
|
||||||
|
display: grid;
|
||||||
|
padding: var(--space-2) 0;
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* From tablet width on, day and title side by side. The day column has a fixed
|
||||||
|
width, so every title starts on the same line; it fits the longest single day,
|
||||||
|
"30. September". A span across months ("30. Januar – 2. Februar") wraps after
|
||||||
|
the dash inside the column. Same font size for both and baseline alignment, so
|
||||||
|
the two texts sit on one line. */
|
||||||
|
@media (min-width: 40rem) {
|
||||||
|
.past-events li {
|
||||||
|
grid-template-columns: 8rem 1fr;
|
||||||
|
column-gap: var(--space-3);
|
||||||
|
align-items: baseline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tabular digits give every day the same width, so "1." and "10." line up. */
|
||||||
|
.past-event-date {
|
||||||
|
color: var(--color-text-soft);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.past-event-title {
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The time trails the title, quieter, as an addition rather than part of the name. */
|
||||||
|
.past-event-time {
|
||||||
|
margin-left: var(--space-2);
|
||||||
|
color: var(--color-text-soft);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -27,7 +27,7 @@ else
|
|||||||
{
|
{
|
||||||
@foreach (var topic in _topics)
|
@foreach (var topic in _topics)
|
||||||
{
|
{
|
||||||
<details class="faq-topic">
|
<details class="faq-topic disclosure">
|
||||||
<summary>@topic.Title</summary>
|
<summary>@topic.Title</summary>
|
||||||
@if (!string.IsNullOrWhiteSpace(topic.Intro))
|
@if (!string.IsNullOrWhiteSpace(topic.Intro))
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
/* Same centred 42rem reading column as a content page (ContentPage.razor.css), so
|
||||||
|
every page of the site shares one column under the header. The topic cards and
|
||||||
|
the questions inside them live in the global app.css. */
|
||||||
|
h1,
|
||||||
|
article,
|
||||||
|
p,
|
||||||
|
.faq-topic {
|
||||||
|
box-sizing: border-box; /* padding inside the 42rem, flush with the text */
|
||||||
|
max-width: 42rem;
|
||||||
|
margin-inline: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The intro's last paragraph drops its margin (app.css), so the first card would
|
||||||
|
sit right under the text; this gives it the same air as between sections. */
|
||||||
|
article {
|
||||||
|
margin-bottom: var(--space-5);
|
||||||
|
}
|
||||||
@@ -1,12 +1,82 @@
|
|||||||
@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)
|
||||||
|
{
|
||||||
|
@* As in EventCard: the calendar sheet shows the date and is hidden from
|
||||||
|
screen readers, who get the full date from <EventDate> instead. Visible
|
||||||
|
beside the sheet is only what it cannot show: the time, the end. *@
|
||||||
|
var span = EventDateFormat.Span(_nextEvent);
|
||||||
|
<a class="next-event" href="/events">
|
||||||
|
<span class="next-event-label">Nächster Termin</span>
|
||||||
|
<span class="next-event-row">
|
||||||
|
<CalendarSheet Value="_nextEvent.Start" />
|
||||||
|
<span class="next-event-details">
|
||||||
|
<span class="next-event-title">@_nextEvent.Title</span>
|
||||||
|
<span class="visually-hidden"><EventDate Value="_nextEvent" /></span>
|
||||||
|
@if (span.Length > 0)
|
||||||
|
{
|
||||||
|
<span class="next-event-meta"><Icon Name="clock" Size="16" /> @span</span>
|
||||||
|
}
|
||||||
|
@if (!string.IsNullOrWhiteSpace(_nextEvent.Location))
|
||||||
|
{
|
||||||
|
<span class="next-event-meta"><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,63 @@ 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 fails during this request -- the static fallback
|
||||||
private IReadOnlyList<string> _embed = [];
|
// heading stands, so 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;
|
||||||
|
|
||||||
|
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. A PocketBase that
|
||||||
|
// is plainly down never gets this far -- the maintenance gate in Program.cs
|
||||||
|
// answers with maintenance.html first. These catches are the second line: the
|
||||||
|
// gate's health result is cached for a few seconds, so PocketBase can fail between
|
||||||
|
// that check and these reads. Without them that would be a 500; with them the
|
||||||
|
// start page stays a 200 rather than a 503 -- degraded, not broken, and rare.
|
||||||
|
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,311 @@
|
|||||||
.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-focus-inverse);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* On a phone the buttons used to wrap onto a row each: two wide blocks stacked,
|
||||||
|
far too heavy for the top of the page. Instead they share one row in equal
|
||||||
|
columns -- the same buttons as on the desktop, just side by side and a little
|
||||||
|
tighter. Short labels ("Termine", "Kontakt") fit on one line; a longer one
|
||||||
|
wraps inside its button (centred) rather than pushing the other off the row. */
|
||||||
|
@media (max-width: 40rem) {
|
||||||
|
.hero-actions {
|
||||||
|
display: grid;
|
||||||
|
grid-auto-flow: column;
|
||||||
|
grid-auto-columns: 1fr;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero .btn {
|
||||||
|
justify-content: center;
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- 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-color: var(--color-focus-inverse);
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Time and place, one line each with their icon, green as on the event cards. The
|
||||||
|
icon belongs to Icon's own scope, so it is reached through ::deep. */
|
||||||
|
.next-event-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-1);
|
||||||
|
margin-top: var(--space-1);
|
||||||
|
color: var(--color-text-soft);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.next-event-meta ::deep .icon {
|
||||||
|
color: var(--color-accent-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
namespace Elternbeirat.Web.Features.Layout;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Works out the year shown in the footer's copyright line.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The container runs on UTC, but visitors live in Germany. Taking the year
|
||||||
|
/// straight from the server clock would show the old year for the first hour
|
||||||
|
/// (two in summer time) of every new year, so the instant is converted to
|
||||||
|
/// Europe/Berlin first.
|
||||||
|
/// </remarks>
|
||||||
|
public static class FooterYear
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// IANA id; resolves on every platform .NET supports, Windows included.
|
||||||
|
/// </summary>
|
||||||
|
private static readonly TimeZoneInfo Berlin =
|
||||||
|
TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin");
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the calendar year in Berlin at the given instant.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="now">
|
||||||
|
/// The current instant, in any offset; usually
|
||||||
|
/// <see cref="TimeProvider.GetUtcNow"/>.
|
||||||
|
/// </param>
|
||||||
|
/// <returns>The year a visitor in Germany would see on their calendar.</returns>
|
||||||
|
/// <example>
|
||||||
|
/// <code>
|
||||||
|
/// FooterYear.For(new DateTimeOffset(2026, 12, 31, 23, 30, 0, TimeSpan.Zero)); // 2027
|
||||||
|
/// </code>
|
||||||
|
/// </example>
|
||||||
|
public static int For(DateTimeOffset now) =>
|
||||||
|
TimeZoneInfo.ConvertTime(now, Berlin).Year;
|
||||||
|
}
|
||||||
@@ -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}";
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
@*
|
||||||
|
The site's own small mark: a speech bubble (the board speaks for the parents)
|
||||||
|
holding a grown-up and a child. Inline SVG, so there is no extra request and
|
||||||
|
nothing from a third-party host. Decorative only -- the name next to it carries
|
||||||
|
the meaning, so it is hidden from screen readers. Colours come from tokens in
|
||||||
|
Signet.razor.css; the size is set by whoever places it (MainLayout.razor.css).
|
||||||
|
*@
|
||||||
|
<svg class="signet" viewBox="0 0 40 40" width="40" height="40" aria-hidden="true" focusable="false">
|
||||||
|
<path class="signet-bubble"
|
||||||
|
d="M8 4h24a6 6 0 0 1 6 6v15a6 6 0 0 1-6 6H18l-8 6v-6H8a6 6 0 0 1-6-6V10a6 6 0 0 1 6-6z" />
|
||||||
|
<g class="signet-figures">
|
||||||
|
<circle cx="15" cy="12" r="3.5" />
|
||||||
|
<path d="M9 26c0-5 2.7-8 6-8s6 3 6 8z" />
|
||||||
|
<circle cx="26" cy="16" r="2.6" />
|
||||||
|
<path d="M21.5 26c0-3.8 2-6 4.5-6s4.5 2.2 4.5 6z" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
/* A white bubble with green figures. It sits on the header gradient and on the dark
|
||||||
|
footer, both of which always carry white text, so --color-on-header is right in
|
||||||
|
both places. The figures use the darker green so they stay distinct on the white
|
||||||
|
bubble (the lighter --color-accent would wash out). */
|
||||||
|
.signet-bubble {
|
||||||
|
fill: var(--color-on-header);
|
||||||
|
}
|
||||||
|
|
||||||
|
.signet-figures {
|
||||||
|
fill: var(--color-accent-dark);
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
/* A content page is one reading column: its text, boxes and card grids all stop at
|
||||||
|
42rem (app.css). Left-aligned in the 72rem layout that column left a wide empty
|
||||||
|
strip on the right of a desktop screen; centred, the empty space splits evenly and
|
||||||
|
the page sits balanced under the header. Title and body share the column, so they
|
||||||
|
keep one left edge. The children inside keep margin 0 and simply fill it. */
|
||||||
|
h1,
|
||||||
|
article,
|
||||||
|
p {
|
||||||
|
max-width: 42rem;
|
||||||
|
margin-inline: auto;
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
@*
|
||||||
|
One post as a card that links to the post: date, title, a teaser from the body
|
||||||
|
and "Weiterlesen". The arrow is a hint where the card leads; it does not move.
|
||||||
|
*@
|
||||||
|
<a class="@CssClass" href="@Href">
|
||||||
|
<span class="post-date"><Icon Name="calendar" Size="16" /><PostDate Value="Value.Date" /></span>
|
||||||
|
<span class="post-title">@Value.Title</span>
|
||||||
|
@if (Teaser.Length > 0)
|
||||||
|
{
|
||||||
|
<span class="post-teaser">@Teaser</span>
|
||||||
|
}
|
||||||
|
<span class="post-more">Weiterlesen <Icon Name="arrow-right" Size="16" /></span>
|
||||||
|
</a>
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
using Elternbeirat.Contracts;
|
||||||
|
using Elternbeirat.Web.Shared;
|
||||||
|
using Microsoft.AspNetCore.Components;
|
||||||
|
|
||||||
|
namespace Elternbeirat.Web.Features.Posts;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Renders one post as a card linking to the post, with date, title and teaser.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Shared by the posts page and the home-page posts embed, so both show posts the
|
||||||
|
/// same way. The teaser comes from the body (<see cref="Markdown.Teaser"/>): posts
|
||||||
|
/// have no summary field of their own.
|
||||||
|
/// </remarks>
|
||||||
|
/// <seealso cref="PostDate"/>
|
||||||
|
public partial class PostCard
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The post to show.
|
||||||
|
/// </summary>
|
||||||
|
[Parameter]
|
||||||
|
[EditorRequired]
|
||||||
|
public Post Value { get; set; } = null!;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// <see langword="true"/> for the large card that opens the posts page (the
|
||||||
|
/// newest post); <see langword="false"/> for a regular card.
|
||||||
|
/// </summary>
|
||||||
|
[Parameter]
|
||||||
|
public bool Featured { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The card's classes; the featured card adds its modifier.
|
||||||
|
/// </summary>
|
||||||
|
private string CssClass => Featured ? "post-card post-card-featured" : "post-card";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The address of the post.
|
||||||
|
/// </summary>
|
||||||
|
private string Href => $"/posts/{Value.Slug}";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The teaser text; empty if the body has no paragraph text.
|
||||||
|
/// </summary>
|
||||||
|
private string Teaser => Markdown.Teaser(Value.Body);
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
/* The post card: a white surface with a green stripe on the left as the eye's
|
||||||
|
anchor. The stripe is a border, so it follows the rounded corners. The whole card
|
||||||
|
is the link and fills its list cell, so the cards of a row are equally tall.
|
||||||
|
Hover = stronger shadow, nothing moves. */
|
||||||
|
.post-card {
|
||||||
|
box-sizing: border-box;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-2);
|
||||||
|
height: 100%;
|
||||||
|
padding: var(--space-3) var(--space-4);
|
||||||
|
border-left: 4px solid var(--color-accent);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--color-bg);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
color: var(--color-text);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: box-shadow var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-card:hover {
|
||||||
|
box-shadow: var(--shadow-hover);
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Icon and date on one line. The icon belongs to Icon's own scope, so it is
|
||||||
|
reached through ::deep. */
|
||||||
|
.post-date {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-date ::deep .icon {
|
||||||
|
color: var(--color-accent-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-title {
|
||||||
|
color: var(--color-brand);
|
||||||
|
font-size: 1.15rem;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Cut after three lines, so cards of a row stay close in height whatever the
|
||||||
|
editor wrote. The featured card lifts the limit (see below). */
|
||||||
|
.post-teaser {
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
-webkit-line-clamp: 3;
|
||||||
|
line-clamp: 3;
|
||||||
|
overflow: hidden;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* margin-top: auto pushes the link line to the bottom, so it sits on one level
|
||||||
|
across a row of cards with teasers of different length. */
|
||||||
|
.post-more {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-1);
|
||||||
|
margin-top: auto;
|
||||||
|
padding-top: var(--space-1);
|
||||||
|
color: var(--color-brand);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The newest post on the posts page: more room, a larger title and the full
|
||||||
|
teaser. */
|
||||||
|
.post-card-featured {
|
||||||
|
padding: var(--space-4) var(--space-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-card-featured .post-title {
|
||||||
|
font-size: var(--font-size-h3);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 48rem) {
|
||||||
|
.post-card-featured .post-title {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-card-featured .post-teaser {
|
||||||
|
display: block;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
@@ -8,16 +8,39 @@
|
|||||||
else if (_post is not null)
|
else if (_post is not null)
|
||||||
{
|
{
|
||||||
<PageTitle>@_post.Title</PageTitle>
|
<PageTitle>@_post.Title</PageTitle>
|
||||||
<nav>
|
@* A plain link, not a <nav>: one link is no navigation block, and an unlabelled
|
||||||
<a href="/posts">← Alle Beiträge</a>
|
second <nav> next to the main menu would only add noise for screen readers. *@
|
||||||
</nav>
|
<a class="btn btn-quiet" href="/posts"><Icon Name="arrow-left" Size="18" />Alle Beiträge</a>
|
||||||
<article>
|
|
||||||
<div class="post-content">
|
<article class="post">
|
||||||
<PostDate Value="_post.Date" />
|
<header class="post-header">
|
||||||
<h2>@_post.Title</h2>
|
<p class="post-meta"><Icon Name="calendar" Size="16" /><PostDate Value="_post.Date" /></p>
|
||||||
<div class="markdown-body">@Body</div>
|
<h1>@_post.Title</h1>
|
||||||
</div>
|
</header>
|
||||||
|
<div class="markdown-body post-body">@Body</div>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
|
@if (_neighbors.Previous is not null || _neighbors.Next is not null)
|
||||||
|
{
|
||||||
|
@* Older post on the left, newer on the right, like a timeline. A missing side
|
||||||
|
leaves its cell empty, so the remaining link keeps its place. *@
|
||||||
|
<nav class="post-pager" aria-label="Weitere Beiträge">
|
||||||
|
@if (_neighbors.Previous is { } previous)
|
||||||
|
{
|
||||||
|
<a class="post-pager-link post-pager-previous" href="/posts/@previous.Slug" rel="prev">
|
||||||
|
<span class="post-pager-label"><Icon Name="arrow-left" Size="16" />Vorheriger Beitrag</span>
|
||||||
|
<span class="post-pager-title">@previous.Title</span>
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
@if (_neighbors.Next is { } next)
|
||||||
|
{
|
||||||
|
<a class="post-pager-link post-pager-next" href="/posts/@next.Slug" rel="next">
|
||||||
|
<span class="post-pager-label">Nächster Beitrag<Icon Name="arrow-right" Size="16" /></span>
|
||||||
|
<span class="post-pager-title">@next.Title</span>
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
</nav>
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@* No post and not unavailable: the slug was unknown; NavigationManager.NotFound()
|
@* No post and not unavailable: the slug was unknown; NavigationManager.NotFound()
|
||||||
already ran in OnParametersSetAsync and renders the not-found page. *@
|
already ran in OnParametersSetAsync and renders the not-found page. *@
|
||||||
@@ -32,6 +32,10 @@ public partial class PostDetail
|
|||||||
|
|
||||||
private Post? _post;
|
private Post? _post;
|
||||||
|
|
||||||
|
// The older and newer post for the "Vorheriger / Nächster Beitrag" links at the
|
||||||
|
// end; both empty until loaded or when the post stands alone.
|
||||||
|
private PostNeighbors _neighbors = new(null, null);
|
||||||
|
|
||||||
// Set when the content source could not be reached or read. The markup then
|
// Set when the content source could not be reached or read. The markup then
|
||||||
// shows a "temporarily unavailable" note instead of a not-found page, matching
|
// shows a "temporarily unavailable" note instead of a not-found page, matching
|
||||||
// the 503 this sets on the response.
|
// the 503 this sets on the response.
|
||||||
@@ -48,6 +52,7 @@ public partial class PostDetail
|
|||||||
var posts = await PocketBase.GetPostsAsync(HttpContext?.RequestAborted ?? CancellationToken.None);
|
var posts = await PocketBase.GetPostsAsync(HttpContext?.RequestAborted ?? CancellationToken.None);
|
||||||
_post = posts.FirstOrDefault(
|
_post = posts.FirstOrDefault(
|
||||||
post => string.Equals(post.Slug, Slug, StringComparison.OrdinalIgnoreCase));
|
post => string.Equals(post.Slug, Slug, StringComparison.OrdinalIgnoreCase));
|
||||||
|
_neighbors = PostNeighbors.Of(posts, Slug);
|
||||||
}
|
}
|
||||||
catch (PocketBaseUnavailableException exception)
|
catch (PocketBaseUnavailableException exception)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,15 +1,116 @@
|
|||||||
/* Push the whole article body down from the "all posts" link above it. Wraps the
|
/* The article: the site's centred 42rem reading column (as on a content page,
|
||||||
date, heading and body, so scoped CSS applies (the date sits in the PostDate
|
ContentPage.razor.css) with generous leading, so a long post reads like a page,
|
||||||
child component and would not be reachable on its own). The date's own look
|
not like a form. Everything inside follows this width -- the global 42rem cap on
|
||||||
comes from PostDate.razor.css. */
|
paragraphs, lists and boxes is lifted here, so nothing stops short of the column
|
||||||
.post-content {
|
(the post's own leading makes the text a touch wider than elsewhere). */
|
||||||
margin-top: 1.5rem;
|
.post {
|
||||||
|
max-width: 42rem;
|
||||||
|
margin: var(--space-4) auto 0;
|
||||||
|
line-height: 1.75;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The post title is the first h2 in the content, sitting right under its date, so
|
/* The back link is an inline button, which margin: auto cannot centre. A left
|
||||||
drop the large default heading gap above it (h1..h4 carry --space-5 top margin
|
margin of half the free space puts it on the column's left edge instead, and
|
||||||
globally); date and title belong together. Scoped to :first-of-type so h2
|
max() keeps it at 0 once the screen is narrower than the column. */
|
||||||
subheadings inside the Markdown body keep their normal spacing. */
|
.btn-quiet {
|
||||||
.post-content h2:first-of-type {
|
margin-left: max(0px, (100% - 42rem) / 2);
|
||||||
margin-top: var(--space-1);
|
}
|
||||||
|
|
||||||
|
.post ::deep :is(p, ul, ol, blockquote, .aufruf, .hinweis) {
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Date above the title as a quiet meta line, with the same calendar icon as on the
|
||||||
|
post cards. The title follows directly, so the gap between them stays small
|
||||||
|
(h1 carries no top margin globally). */
|
||||||
|
.post-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-1);
|
||||||
|
margin: 0 0 var(--space-2);
|
||||||
|
color: var(--color-text-soft);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-meta ::deep .icon {
|
||||||
|
color: var(--color-accent-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The first paragraph as the lead: larger and in the soft tone, so it reads as an
|
||||||
|
introduction. Pure CSS on the editor's Markdown -- if a post starts with a
|
||||||
|
heading or a list instead, there simply is no lead. Same text colour token as
|
||||||
|
the rest, so it keeps AA contrast in both schemes. */
|
||||||
|
.post-body ::deep > p:first-child {
|
||||||
|
color: var(--color-text-soft);
|
||||||
|
font-size: 1.2rem;
|
||||||
|
line-height: 1.65;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Older/newer post at the end: two cards side by side, older on the left, newer on
|
||||||
|
the right. The grid keeps each in its column even if the other is missing. Hover
|
||||||
|
= border and shadow, nothing moves. */
|
||||||
|
.post-pager {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: var(--space-3);
|
||||||
|
max-width: 42rem;
|
||||||
|
margin: var(--space-6) auto 0;
|
||||||
|
padding-top: var(--space-4);
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-pager-link {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-1);
|
||||||
|
padding: var(--space-3) var(--space-4);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--color-bg);
|
||||||
|
color: var(--color-text);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: box-shadow var(--transition), border-color var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-pager-link:hover {
|
||||||
|
border-color: var(--color-brand);
|
||||||
|
box-shadow: var(--shadow-hover);
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-pager-next {
|
||||||
|
grid-column: 2;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-pager-label {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-1);
|
||||||
|
color: var(--color-text-soft);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-pager-next .post-pager-label {
|
||||||
|
align-self: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-pager-title {
|
||||||
|
color: var(--color-brand);
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* On a phone the two cards stack: a half-width card would squeeze a long title
|
||||||
|
into a narrow column. Older first, as in the wide layout. */
|
||||||
|
@media (max-width: 40rem) {
|
||||||
|
.post-pager {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-pager-next {
|
||||||
|
grid-column: 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -25,15 +25,12 @@ else if (_posts.Count == 0)
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<ul class="post-list">
|
@* The newest post (GetPostsAsync sorts newest first) opens the page as a large
|
||||||
@foreach (var post in _posts)
|
card across the full width; the rest follow in the grid. *@
|
||||||
|
<ul class="post-list post-list-grid">
|
||||||
|
@for (var i = 0; i < _posts.Count; i++)
|
||||||
{
|
{
|
||||||
<li>
|
<li class="@(i == 0 ? "post-list-featured" : null)"><PostCard Value="_posts[i]" Featured="i == 0" /></li>
|
||||||
<a class="card-link" href="@($"/posts/{post.Slug}")">
|
|
||||||
<span class="post-title">@post.Title</span>
|
|
||||||
<PostDate Value="post.Date" />
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
}
|
}
|
||||||
</ul>
|
</ul>
|
||||||
}
|
}
|
||||||
@@ -1,2 +1,29 @@
|
|||||||
/* The .post-list card styles are shared with the home-page embed and live in the
|
/* The card is PostCard and the base list grid lives in the global app.css, shared
|
||||||
global app.css. This page needs no extra styling of its own. */
|
with the home-page embed. Only the page's two-column layout is here: one column
|
||||||
|
on a phone, two from tablet width, with the newest post across both. */
|
||||||
|
@media (min-width: 48rem) {
|
||||||
|
.post-list-grid {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-list-featured {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Same centred 42rem reading column as a content page (ContentPage.razor.css), so
|
||||||
|
every page of the site shares one column under the header. Two cards still fit
|
||||||
|
side by side in it. */
|
||||||
|
h1,
|
||||||
|
article,
|
||||||
|
p,
|
||||||
|
.post-list {
|
||||||
|
max-width: 42rem;
|
||||||
|
margin-inline: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The intro's last paragraph drops its margin (app.css), so the first card would
|
||||||
|
sit right under the text; this gives it the same air as between sections. */
|
||||||
|
article {
|
||||||
|
margin-bottom: var(--space-5);
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
using Elternbeirat.Contracts;
|
||||||
|
|
||||||
|
namespace Elternbeirat.Web.Features.Posts;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The posts next to a given post in time: the one published before it and the
|
||||||
|
/// one published after it.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="Previous">
|
||||||
|
/// The older post, or <see langword="null"/> if the post is the oldest.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="Next">
|
||||||
|
/// The newer post, or <see langword="null"/> if the post is the newest.
|
||||||
|
/// </param>
|
||||||
|
/// <remarks>
|
||||||
|
/// "Previous" and "next" follow time, as a reader moving through the news would:
|
||||||
|
/// "Vorheriger Beitrag" is the older one, "Nächster Beitrag" the newer one. The
|
||||||
|
/// list order of the posts page (newest first) is the other way round, which is
|
||||||
|
/// why the direction is pinned down here instead of being left to the markup.
|
||||||
|
/// </remarks>
|
||||||
|
/// <seealso cref="PostDetail"/>
|
||||||
|
public sealed record PostNeighbors(Post? Previous, Post? Next)
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Finds the neighbours of the post with the given slug.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="posts">
|
||||||
|
/// All public posts, newest first, as <c>PocketBaseClient.GetPostsAsync</c>
|
||||||
|
/// returns them.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="slug">The slug of the post being shown.</param>
|
||||||
|
/// <returns>
|
||||||
|
/// The older and the newer post. Both are <see langword="null"/> if the slug is
|
||||||
|
/// not in the list, or if it is the only post.
|
||||||
|
/// </returns>
|
||||||
|
/// <remarks>
|
||||||
|
/// The slug is compared case-insensitively, like the route lookup in
|
||||||
|
/// <see cref="PostDetail"/>, so a page reached as <c>/posts/New-Board</c> still
|
||||||
|
/// gets its neighbours.
|
||||||
|
/// </remarks>
|
||||||
|
/// <example>
|
||||||
|
/// <code>
|
||||||
|
/// // posts = [March, February, January] (newest first)
|
||||||
|
/// var neighbors = PostNeighbors.Of(posts, "february");
|
||||||
|
/// // neighbors.Previous is January, neighbors.Next is March
|
||||||
|
/// </code>
|
||||||
|
/// </example>
|
||||||
|
public static PostNeighbors Of(IReadOnlyList<Post> posts, string slug) =>
|
||||||
|
IndexOf(posts, slug) is var index and >= 0
|
||||||
|
? new PostNeighbors(
|
||||||
|
Previous: index + 1 < posts.Count ? posts[index + 1] : null,
|
||||||
|
Next: index > 0 ? posts[index - 1] : null)
|
||||||
|
: new PostNeighbors(null, null);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The position of the post with <paramref name="slug"/>, or <c>-1</c>.
|
||||||
|
/// </summary>
|
||||||
|
private static int IndexOf(IReadOnlyList<Post> posts, string slug) =>
|
||||||
|
posts
|
||||||
|
.Select((post, index) => (post, index))
|
||||||
|
.FirstOrDefault(
|
||||||
|
entry => string.Equals(entry.post.Slug, slug, StringComparison.OrdinalIgnoreCase),
|
||||||
|
(null!, -1))
|
||||||
|
.index;
|
||||||
|
}
|
||||||
@@ -2,12 +2,18 @@ using Elternbeirat.Contracts;
|
|||||||
using Elternbeirat.PocketBase;
|
using Elternbeirat.PocketBase;
|
||||||
using Elternbeirat.Web.Components;
|
using Elternbeirat.Web.Components;
|
||||||
using Elternbeirat.Web.Features.Events;
|
using Elternbeirat.Web.Features.Events;
|
||||||
|
using Elternbeirat.Web.Shared;
|
||||||
|
using Microsoft.AspNetCore.Components.Endpoints;
|
||||||
using Microsoft.AspNetCore.HttpOverrides;
|
using Microsoft.AspNetCore.HttpOverrides;
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
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).
|
||||||
@@ -21,6 +27,10 @@ builder.Services.AddHttpClient<PocketBaseClient>(client =>
|
|||||||
client.Timeout = TimeSpan.FromSeconds(5);
|
client.Timeout = TimeSpan.FromSeconds(5);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Shared by all requests, so the maintenance gate below asks PocketBase at most once
|
||||||
|
// every few seconds rather than on every page view.
|
||||||
|
builder.Services.AddSingleton<PocketBaseHealthCache>();
|
||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
|
||||||
// NPM sits in front of the app and handles HTTPS. Without this line the app would
|
// NPM sits in front of the app and handles HTTPS. Without this line the app would
|
||||||
@@ -44,6 +54,37 @@ if (app.Environment.IsDevelopment() is false)
|
|||||||
// is down (a 503) it would wrongly show "not found" instead of our own message.
|
// is down (a 503) it would wrongly show "not found" instead of our own message.
|
||||||
app.UseAntiforgery();
|
app.UseAntiforgery();
|
||||||
|
|
||||||
|
// Maintenance gate: while PocketBase is down, every page gets the static
|
||||||
|
// maintenance.html with 503 instead of a half-empty page (the menu comes from
|
||||||
|
// PocketBase too). Routing has already picked the endpoint at this point, so the gate
|
||||||
|
// covers exactly the Razor component pages: /health, the calendar feeds, the static
|
||||||
|
// assets (incl. maintenance.html and its font) are not components and answer for
|
||||||
|
// themselves. A new endpoint is thus left alone unless it is a page. The health
|
||||||
|
// result is cached (PocketBaseHealthCache), so the page may come back a few seconds
|
||||||
|
// after PocketBase does. Retry-After tells well-behaved clients and crawlers when to
|
||||||
|
// come back; no-store keeps the 503 page out of any cache.
|
||||||
|
app.Use(async (context, next) =>
|
||||||
|
{
|
||||||
|
var isPage = context.GetEndpoint()?.Metadata.GetMetadata<ComponentTypeMetadata>() is not null;
|
||||||
|
var services = context.RequestServices;
|
||||||
|
if (!isPage
|
||||||
|
|| await services.GetRequiredService<PocketBaseHealthCache>().IsHealthyAsync(
|
||||||
|
services.GetRequiredService<PocketBaseClient>().IsHealthyAsync,
|
||||||
|
context.RequestAborted))
|
||||||
|
{
|
||||||
|
await next(context);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
context.Response.StatusCode = StatusCodes.Status503ServiceUnavailable;
|
||||||
|
context.Response.Headers.RetryAfter = "60";
|
||||||
|
context.Response.Headers.CacheControl = "no-store";
|
||||||
|
context.Response.ContentType = "text/html; charset=utf-8";
|
||||||
|
await context.Response.SendFileAsync(
|
||||||
|
app.Environment.WebRootFileProvider.GetFileInfo("maintenance.html"),
|
||||||
|
context.RequestAborted);
|
||||||
|
});
|
||||||
|
|
||||||
app.MapStaticAssets();
|
app.MapStaticAssets();
|
||||||
app.MapRazorComponents<App>();
|
app.MapRazorComponents<App>();
|
||||||
|
|
||||||
@@ -65,6 +106,28 @@ app.MapGet("/events.ics", async (PocketBaseClient pocketBase, CancellationToken
|
|||||||
return Results.Text(IcsCalendar.Build(events), "text/calendar; charset=utf-8");
|
return Results.Text(IcsCalendar.Build(events), "text/calendar; charset=utf-8");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// One event as a calendar file ("In Kalender übernehmen" on the events page): the
|
||||||
|
// same calendar as the feed, holding just this event. Unlike the feed it does not
|
||||||
|
// fall back to an empty calendar when PocketBase is down -- a file without the event
|
||||||
|
// the visitor clicked would be a silent failure, so it answers 503. An unknown or
|
||||||
|
// non-public id is a 404.
|
||||||
|
app.MapGet("/events/{id}.ics", async (string id, PocketBaseClient pocketBase, CancellationToken token) =>
|
||||||
|
{
|
||||||
|
IReadOnlyList<Event> events;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
events = await pocketBase.GetEventsAsync(token);
|
||||||
|
}
|
||||||
|
catch (PocketBaseUnavailableException)
|
||||||
|
{
|
||||||
|
return Results.StatusCode(StatusCodes.Status503ServiceUnavailable);
|
||||||
|
}
|
||||||
|
|
||||||
|
return events.FirstOrDefault(@event => @event.Id == id) is { } match
|
||||||
|
? Results.Text(IcsCalendar.Build([match]), "text/calendar; charset=utf-8")
|
||||||
|
: Results.NotFound();
|
||||||
|
});
|
||||||
|
|
||||||
// Status page for an outside monitor (Uptime Kuma) to check. One check tells all
|
// Status page for an outside monitor (Uptime Kuma) to check. One check tells all
|
||||||
// three states apart: no reply = the app is down, 503 = the app runs but PocketBase
|
// three states apart: no reply = the app is down, 503 = the app runs but PocketBase
|
||||||
// is down, 200 = both are fine. It talks to PocketBase directly, not through the
|
// is down, 200 = both are fine. It talks to PocketBase directly, not through the
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -12,7 +12,8 @@ namespace Elternbeirat.Web.Shared;
|
|||||||
/// <para>
|
/// <para>
|
||||||
/// Source: Lucide (<c>lucide.dev</c>), ISC license; the icons derived from
|
/// Source: Lucide (<c>lucide.dev</c>), ISC license; the icons derived from
|
||||||
/// Feather (<c>calendar</c>, <c>clock</c>, <c>download</c>,
|
/// Feather (<c>calendar</c>, <c>clock</c>, <c>download</c>,
|
||||||
/// <c>external-link</c>, <c>help-circle</c>, <c>info</c>, <c>arrow-right</c>)
|
/// <c>external-link</c>, <c>help-circle</c>, <c>info</c>, <c>arrow-right</c>,
|
||||||
|
/// <c>arrow-left</c>)
|
||||||
/// are MIT, Copyright (c) 2013-present Cole Bemis. Both licenses only ask that
|
/// are MIT, Copyright (c) 2013-present Cole Bemis. Both licenses only ask that
|
||||||
/// the notice travels with the copy, which this comment does. Only the inner
|
/// the notice travels with the copy, which this comment does. Only the inner
|
||||||
/// elements are stored; <see cref="Icon"/> supplies the shared
|
/// elements are stored; <see cref="Icon"/> supplies the shared
|
||||||
@@ -55,6 +56,8 @@ public static class IconSet
|
|||||||
"""<circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><path d="M12 17h.01"/>""",
|
"""<circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><path d="M12 17h.01"/>""",
|
||||||
["newspaper"] =
|
["newspaper"] =
|
||||||
"""<path d="M15 18h-5"/><path d="M18 14h-8"/><path d="M4 22h16a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2H8a2 2 0 0 0-2 2v16a2 2 0 0 1-4 0v-9a2 2 0 0 1 2-2h2"/><rect width="8" height="4" x="10" y="6" rx="1"/>""",
|
"""<path d="M15 18h-5"/><path d="M18 14h-8"/><path d="M4 22h16a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2H8a2 2 0 0 0-2 2v16a2 2 0 0 1-4 0v-9a2 2 0 0 1 2-2h2"/><rect width="8" height="4" x="10" y="6" rx="1"/>""",
|
||||||
|
["arrow-left"] =
|
||||||
|
"""<path d="m12 19-7-7 7-7"/><path d="M19 12H5"/>""",
|
||||||
["arrow-right"] =
|
["arrow-right"] =
|
||||||
"""<path d="M5 12h14"/><path d="m12 5 7 7-7 7"/>""",
|
"""<path d="M5 12h14"/><path d="m12 5 7 7-7 7"/>""",
|
||||||
["external-link"] =
|
["external-link"] =
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
|
using System.Text;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
using Markdig;
|
using Markdig;
|
||||||
|
using Markdig.Extensions.CustomContainers;
|
||||||
using Markdig.Renderers.Html;
|
using Markdig.Renderers.Html;
|
||||||
using Markdig.Syntax;
|
using Markdig.Syntax;
|
||||||
using Markdig.Syntax.Inlines;
|
using Markdig.Syntax.Inlines;
|
||||||
@@ -18,8 +21,8 @@ namespace Elternbeirat.Web.Shared;
|
|||||||
/// <b>Custom containers</b> (<c>::: name</c> … <c>:::</c>) turn a block
|
/// <b>Custom containers</b> (<c>::: name</c> … <c>:::</c>) turn a block
|
||||||
/// into <c><div class="name"></c>. That is how editors use the
|
/// into <c><div class="name"></c>. That is how editors use the
|
||||||
/// design blocks (<c>kennzahlen</c>, <c>aufruf</c>, <c>kacheln</c>,
|
/// design blocks (<c>kennzahlen</c>, <c>aufruf</c>, <c>kacheln</c>,
|
||||||
/// <c>hinweis</c>) without writing HTML. An unknown name just yields a
|
/// <c>hinweis</c>, <c>team</c>) without writing HTML. An unknown name
|
||||||
/// div without styling, so a typo never breaks a page.
|
/// just yields a div without styling, so a typo never breaks a page.
|
||||||
/// </description>
|
/// </description>
|
||||||
/// </item>
|
/// </item>
|
||||||
/// <item>
|
/// <item>
|
||||||
@@ -43,9 +46,15 @@ namespace Elternbeirat.Web.Shared;
|
|||||||
/// the text around an element), but the stylesheet needs exactly that to show a
|
/// the text around an element), but the stylesheet needs exactly that to show a
|
||||||
/// lone <c>mailto:</c> link as a button and a lone PDF link as a file card.
|
/// lone <c>mailto:</c> link as a button and a lone PDF link as a file card.
|
||||||
/// </para>
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// The list items of a <c>::: team</c> block become person cards
|
||||||
|
/// (<see cref="TeamMember"/>): the initials avatar and the split into name,
|
||||||
|
/// role and duties need the text itself, which CSS cannot read.
|
||||||
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
/// <seealso cref="IconSet"/>
|
/// <seealso cref="IconSet"/>
|
||||||
public static class Markdown
|
/// <seealso cref="TeamMember"/>
|
||||||
|
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.
|
||||||
@@ -53,6 +62,12 @@ public static class Markdown
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public const string LoneLinkClass = "lone-link";
|
public const string LoneLinkClass = "lone-link";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The default teaser length: about two lines on a card, enough to say what a
|
||||||
|
/// post is about without turning the list into a wall of text.
|
||||||
|
/// </summary>
|
||||||
|
public const int TeaserLength = 160;
|
||||||
|
|
||||||
private static readonly MarkdownPipeline Pipeline =
|
private static readonly MarkdownPipeline Pipeline =
|
||||||
new MarkdownPipelineBuilder()
|
new MarkdownPipelineBuilder()
|
||||||
.UseCustomContainers()
|
.UseCustomContainers()
|
||||||
@@ -75,9 +90,232 @@ 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>
|
||||||
|
/// Builds a short plain-text teaser from a Markdown text, e.g. for a post card.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="markdown">The editor's Markdown; may be <see langword="null"/>.</param>
|
||||||
|
/// <param name="maxLength">
|
||||||
|
/// The longest teaser, in characters, before the ellipsis. Defaults to
|
||||||
|
/// <see cref="TeaserLength"/>.
|
||||||
|
/// </param>
|
||||||
|
/// <returns>
|
||||||
|
/// The text of all paragraphs (including those in lists, quotes and <c>:::</c>
|
||||||
|
/// blocks), with all Markdown removed and whitespace collapsed. A longer text is
|
||||||
|
/// cut at the last word boundary within <paramref name="maxLength"/> and ends
|
||||||
|
/// in <c>…</c>. An empty string when there is no paragraph text at all.
|
||||||
|
/// </returns>
|
||||||
|
/// <exception cref="ArgumentOutOfRangeException">
|
||||||
|
/// <paramref name="maxLength"/> is zero or negative.
|
||||||
|
/// </exception>
|
||||||
|
/// <remarks>
|
||||||
|
/// Headings are skipped: on a card the title already heads the teaser. Unlike
|
||||||
|
/// <see cref="FirstSentence"/> the teaser does not stop at the first sentence,
|
||||||
|
/// because a post often opens with a short line ("Liebe Eltern,") that says
|
||||||
|
/// nothing on its own.
|
||||||
|
/// <para>
|
||||||
|
/// Punctuation left dangling at the cut (<c>,</c>, <c>.</c>, <c>:</c> …) is
|
||||||
|
/// dropped, so the teaser never ends in <c>,…</c>.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
/// <example>
|
||||||
|
/// <code>
|
||||||
|
/// Markdown.Teaser("## Rückblick\n\nDer **Basar** war ein voller Erfolg.", 20);
|
||||||
|
/// // "Der Basar war ein…"
|
||||||
|
/// </code>
|
||||||
|
/// </example>
|
||||||
|
public static string Teaser(string? markdown, int maxLength = TeaserLength) =>
|
||||||
|
maxLength <= 0
|
||||||
|
? throw new ArgumentOutOfRangeException(nameof(maxLength), maxLength, "The teaser needs room for at least one character.")
|
||||||
|
: string.IsNullOrWhiteSpace(markdown)
|
||||||
|
? ""
|
||||||
|
: Shorten(
|
||||||
|
string.Join(
|
||||||
|
' ',
|
||||||
|
Parse(markdown)
|
||||||
|
.Descendants<ParagraphBlock>()
|
||||||
|
.Select(paragraph => paragraph.Inline is { } inline ? PlainText(inline) : "")
|
||||||
|
.Where(text => text.Length > 0)),
|
||||||
|
maxLength);
|
||||||
|
|
||||||
|
/// <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, building the team cards
|
||||||
|
/// and 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(BuildTeamCards(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="inlines">
|
||||||
|
/// The inline content, e.g. of a paragraph or heading, or a part of it.
|
||||||
|
/// </param>
|
||||||
|
/// <returns>The visible text, trimmed.</returns>
|
||||||
|
internal static string PlainText(IEnumerable<Inline> inlines) =>
|
||||||
|
Whitespace().Replace(inlines.Aggregate(new StringBuilder(), AppendText).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>
|
||||||
|
/// Cuts a text to a word boundary and marks the cut.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="text">Plain text.</param>
|
||||||
|
/// <param name="maxLength">The longest result before the ellipsis.</param>
|
||||||
|
/// <returns>
|
||||||
|
/// The text unchanged if it fits; otherwise everything up to the last space
|
||||||
|
/// within <paramref name="maxLength"/>, trailing punctuation removed, plus
|
||||||
|
/// <c>…</c>. A single word longer than <paramref name="maxLength"/> is cut hard.
|
||||||
|
/// </returns>
|
||||||
|
/// <remarks>
|
||||||
|
/// The space is searched one character past the limit: if the text breaks
|
||||||
|
/// exactly at <paramref name="maxLength"/>, the last whole word still fits.
|
||||||
|
/// </remarks>
|
||||||
|
private static string Shorten(string text, int maxLength) =>
|
||||||
|
text.Length <= maxLength
|
||||||
|
? text
|
||||||
|
: (text[..(maxLength + 1)].LastIndexOf(' ') is var space and > 0 ? text[..space] : text[..maxLength])
|
||||||
|
.TrimEnd(' ', ',', ';', ':', '.', '-', '–') + "…";
|
||||||
|
|
||||||
|
/// <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>
|
||||||
|
/// Turns the list items of every <c>::: team</c> block into person cards.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="document">The parsed document; changed in place.</param>
|
||||||
|
/// <returns>The same <paramref name="document"/>, for chaining.</returns>
|
||||||
|
/// <remarks>
|
||||||
|
/// Only lists directly in the block count; a nested list inside a card stays a
|
||||||
|
/// list. <c>::: team gross</c> adds the class
|
||||||
|
/// <see cref="TeamMember.LargeModifier"/> to the block, because Markdig only
|
||||||
|
/// turns the block name into a class, not the words after it.
|
||||||
|
/// </remarks>
|
||||||
|
private static MarkdownDocument BuildTeamCards(MarkdownDocument document)
|
||||||
|
{
|
||||||
|
foreach (var team in document.Descendants<CustomContainer>().Where(TeamMember.IsTeam).ToList())
|
||||||
|
{
|
||||||
|
if (TeamMember.IsLarge(team))
|
||||||
|
{
|
||||||
|
team.GetAttributes().AddClass(TeamMember.LargeModifier);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var item in team.OfType<ListBlock>().SelectMany(list => list.OfType<ListItemBlock>()))
|
||||||
|
{
|
||||||
|
TeamMember.ToCard(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return document;
|
||||||
|
}
|
||||||
|
|
||||||
/// <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 +352,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,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
namespace Elternbeirat.Web.Shared;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Remembers for a few seconds whether PocketBase answered its health check, so the
|
||||||
|
/// maintenance gate does not probe PocketBase on every page request.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="time">
|
||||||
|
/// The clock that decides when a result is stale; injected so tests can step past
|
||||||
|
/// <see cref="Lifetime"/> without waiting.
|
||||||
|
/// </param>
|
||||||
|
/// <remarks>
|
||||||
|
/// The gate in <c>Program.cs</c> asks this cache before rendering any page and serves
|
||||||
|
/// the static <c>maintenance.html</c> with 503 while the answer is "unhealthy". A
|
||||||
|
/// failed check is kept just as long as a passed one: while PocketBase is down every
|
||||||
|
/// probe would otherwise wait for the connection to fail again.
|
||||||
|
/// <para>
|
||||||
|
/// The price of caching is a short blind spot in both directions: after
|
||||||
|
/// PocketBase fails, pages may still render for up to <see cref="Lifetime"/> (the
|
||||||
|
/// components' own fallbacks cover that), and after it recovers the maintenance
|
||||||
|
/// page may stay up for as long.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Registered as a singleton. No lock: two requests that find the result stale at
|
||||||
|
/// the same moment both probe, and the later answer wins -- harmless, and cheaper
|
||||||
|
/// than making every request wait on one another.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
/// <example>
|
||||||
|
/// <code>
|
||||||
|
/// var healthy = await cache.IsHealthyAsync(pocketBase.IsHealthyAsync, context.RequestAborted);
|
||||||
|
/// </code>
|
||||||
|
/// </example>
|
||||||
|
/// <seealso cref="PocketBase.PocketBaseClient.IsHealthyAsync"/>
|
||||||
|
public sealed class PocketBaseHealthCache(TimeProvider time)
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// How long a health check result is reused before PocketBase is asked again.
|
||||||
|
/// </summary>
|
||||||
|
public static readonly TimeSpan Lifetime = TimeSpan.FromSeconds(10);
|
||||||
|
|
||||||
|
// One reference, swapped as a whole, so a reader never sees the result of one
|
||||||
|
// check paired with the time of another.
|
||||||
|
private volatile Check? _last;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the last known health of PocketBase, probing again once it is older
|
||||||
|
/// than <see cref="Lifetime"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="probe">
|
||||||
|
/// The actual check, usually <see cref="PocketBase.PocketBaseClient.IsHealthyAsync"/>.
|
||||||
|
/// Passed in rather than injected because the typed client is transient and this
|
||||||
|
/// cache is a singleton.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="token">Cancels the probe, e.g. when the visitor goes away.</param>
|
||||||
|
/// <returns>
|
||||||
|
/// <see langword="true"/> if PocketBase answered its last check (or answers this
|
||||||
|
/// one), otherwise <see langword="false"/>.
|
||||||
|
/// </returns>
|
||||||
|
/// <exception cref="OperationCanceledException">
|
||||||
|
/// <paramref name="token"/> was cancelled during a probe; nothing is cached then.
|
||||||
|
/// </exception>
|
||||||
|
public async Task<bool> IsHealthyAsync(Func<CancellationToken, Task<bool>> probe, CancellationToken token) =>
|
||||||
|
_last is { } last && time.GetUtcNow() - last.At < Lifetime
|
||||||
|
? last.Healthy
|
||||||
|
: (_last = new Check(await probe(token), time.GetUtcNow())).Healthy;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One health check result and when it was taken.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="Healthy">Whether PocketBase answered.</param>
|
||||||
|
/// <param name="At">When the answer came in.</param>
|
||||||
|
private sealed record Check(bool Healthy, DateTimeOffset At);
|
||||||
|
}
|
||||||
@@ -0,0 +1,380 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using Markdig.Extensions.CustomContainers;
|
||||||
|
using Markdig.Renderers.Html;
|
||||||
|
using Markdig.Syntax;
|
||||||
|
using Markdig.Syntax.Inlines;
|
||||||
|
|
||||||
|
namespace Elternbeirat.Web.Shared;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One person in a <c>::: team</c> block, read from a list item that follows the
|
||||||
|
/// convention the board page has always used.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="Name">The person's name: the bold start of the item.</param>
|
||||||
|
/// <param name="Role">
|
||||||
|
/// The role after the name, without its parentheses, or <see langword="null"/>
|
||||||
|
/// when the item has none (most of the "Beisitz" members).
|
||||||
|
/// </param>
|
||||||
|
/// <param name="Duties">
|
||||||
|
/// The responsibilities from the italic second line, split at commas; empty when
|
||||||
|
/// there is no such line.
|
||||||
|
/// </param>
|
||||||
|
/// <remarks>
|
||||||
|
/// The convention, one list item per person:
|
||||||
|
/// <code>
|
||||||
|
/// - **Maik Palm** (Vorsitzender)
|
||||||
|
/// *Schulkonferenz, Mensarat, Homepage*
|
||||||
|
/// Mitglied im Gesamtelternbeirat.
|
||||||
|
/// </code>
|
||||||
|
/// The bold name becomes the card's heading, the role a chip, every duty a small
|
||||||
|
/// tag and any further line the note. Line breaks may be hard (two spaces) or soft:
|
||||||
|
/// editors forget the two spaces, and the card should not depend on them.
|
||||||
|
/// <para>
|
||||||
|
/// The board stays Markdown in the page body on purpose: a dedicated
|
||||||
|
/// collection would be cleaner, but there is no way yet to bring a schema
|
||||||
|
/// change to production (Gitea #39). Reading the convention costs the editors
|
||||||
|
/// nothing they do not already write.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// An item that does not start with a bold name is not an error: it becomes a
|
||||||
|
/// plain card showing its text as written (<see cref="PlainCardClass"/>).
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
/// <seealso cref="Markdown"/>
|
||||||
|
public sealed record TeamMember(string Name, string? Role, IReadOnlyList<string> Duties)
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The block name editors type: <c>::: team</c>.
|
||||||
|
/// </summary>
|
||||||
|
public const string ContainerName = "team";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The modifier for the chair, <c>::: team gross</c>; also the class it adds to
|
||||||
|
/// the block. The cards keep the shared raster; only the role chip is
|
||||||
|
/// highlighted.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// A word after the block name rather than a block of its own
|
||||||
|
/// (<c>::: team-gross</c>): the block stays <c>team</c>, so a typo in the
|
||||||
|
/// modifier only costs the highlight, never the whole grid. <c>groß</c> is
|
||||||
|
/// accepted too; <c>gross</c> is the documented form because it types the same
|
||||||
|
/// on every keyboard.
|
||||||
|
/// </remarks>
|
||||||
|
public const string LargeModifier = "gross";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The class on every card, i.e. on every list item in a <c>::: team</c> block.
|
||||||
|
/// </summary>
|
||||||
|
public const string CardClass = "team-card";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The extra class on a card whose item does not follow the convention.
|
||||||
|
/// </summary>
|
||||||
|
public const string PlainCardClass = "team-card-plain";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// How many avatar colours there are; <c>app.css</c> defines
|
||||||
|
/// <c>--color-avatar-1</c> (brand blue) and <c>--color-avatar-2</c> (brand
|
||||||
|
/// green) to match.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Only the two brand colours: more hues made the board look busy and pulled it
|
||||||
|
/// away from the IGMH palette.
|
||||||
|
/// </remarks>
|
||||||
|
public const int ColorCount = 2;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// FNV-1a offset basis and prime (32 bit), see <see cref="Fnv"/>.
|
||||||
|
/// </summary>
|
||||||
|
private const uint FnvOffset = 2166136261;
|
||||||
|
|
||||||
|
private const uint FnvPrime = 16777619;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The letters on the avatar, see <see cref="InitialsOf"/>.
|
||||||
|
/// </summary>
|
||||||
|
public string Initials => InitialsOf(Name);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The avatar colour, from <c>1</c> to <see cref="ColorCount"/>, see
|
||||||
|
/// <see cref="ColorOf"/>.
|
||||||
|
/// </summary>
|
||||||
|
public int Color => ColorOf(Name);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds the initials shown on a person's avatar.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="name">The person's name.</param>
|
||||||
|
/// <returns>
|
||||||
|
/// The first letter of the first and of the last word, upper case; one letter
|
||||||
|
/// for a single word; an empty string when the name holds no letter at all.
|
||||||
|
/// </returns>
|
||||||
|
/// <remarks>
|
||||||
|
/// A double name counts as one word, so it adds one letter, not two: two
|
||||||
|
/// letters are what fits the circle, and they are what people expect from a
|
||||||
|
/// name like <c>Reger-Stilgenbauer</c>. Letters outside A–Z (<c>Ö</c>,
|
||||||
|
/// <c>Ş</c>) are kept as they are, and leading punctuation is skipped.
|
||||||
|
/// </remarks>
|
||||||
|
/// <example>
|
||||||
|
/// <code>
|
||||||
|
/// TeamMember.InitialsOf("Jenny Reger-Stilgenbauer"); // "JR"
|
||||||
|
/// TeamMember.InitialsOf("Özlem Ünal"); // "ÖÜ"
|
||||||
|
/// TeamMember.InitialsOf("Isabell"); // "I"
|
||||||
|
/// </code>
|
||||||
|
/// </example>
|
||||||
|
public static string InitialsOf(string name) =>
|
||||||
|
name.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries) switch
|
||||||
|
{
|
||||||
|
[] => "",
|
||||||
|
[var only] => Initial(only),
|
||||||
|
[var first, .., var last] => Initial(first) + Initial(last),
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Picks a person's avatar colour from the name.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="name">The person's name.</param>
|
||||||
|
/// <returns>A number from <c>1</c> to <see cref="ColorCount"/>.</returns>
|
||||||
|
/// <remarks>
|
||||||
|
/// The same name always gets the same colour, on every request and after every
|
||||||
|
/// restart, so a person does not change colour between visits. That is why this
|
||||||
|
/// is a fixed hash (FNV-1a) and not <see cref="string.GetHashCode()"/>, which
|
||||||
|
/// .NET randomises per process. Surrounding whitespace does not count.
|
||||||
|
/// <para>
|
||||||
|
/// FNV-1a alone spreads short names badly, so the MurmurHash3 finaliser
|
||||||
|
/// (<see cref="Mix"/>) stirs all bits first. The colour is then taken from
|
||||||
|
/// the <b>high</b> bits (multiply by <see cref="ColorCount"/>, keep the top
|
||||||
|
/// 32 bits) rather than a remainder, which would read only the low bits:
|
||||||
|
/// with two colours that put eight of the ten board members on the same
|
||||||
|
/// one.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public static int ColorOf(string name) =>
|
||||||
|
(int)(((ulong)Mix(Fnv(name.Trim())) * ColorCount) >> 32) + 1;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reads a person from the first paragraph of a list item.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="paragraph">The paragraph to read; not changed.</param>
|
||||||
|
/// <returns>
|
||||||
|
/// The person and the inlines that form the note (everything after the name
|
||||||
|
/// line and, if present, the duties line; possibly empty), or
|
||||||
|
/// <see langword="null"/> when the paragraph does not start with a bold name.
|
||||||
|
/// </returns>
|
||||||
|
/// <remarks>
|
||||||
|
/// The duties line is only recognised when it is nothing but one italic span;
|
||||||
|
/// any other second line is part of the note, so no text is lost.
|
||||||
|
/// </remarks>
|
||||||
|
internal static (TeamMember Member, Inline[] Note)? Read(ParagraphBlock paragraph) =>
|
||||||
|
Lines(paragraph).ToArray() is [var head, .. var rest]
|
||||||
|
&& Content(head) is [EmphasisInline { DelimiterCount: 2 } name, .. var role]
|
||||||
|
&& Markdown.PlainText(name) is { Length: > 0 } nameText
|
||||||
|
? rest is [var second, .. var after] && Content(second) is [EmphasisInline { DelimiterCount: 1 } duties]
|
||||||
|
? (new TeamMember(nameText, RoleOf(role), DutiesOf(duties)), [.. after.SelectMany(line => line)])
|
||||||
|
: (new TeamMember(nameText, RoleOf(role), []), [.. rest.SelectMany(line => line)])
|
||||||
|
: null;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Turns one list item of a <c>::: team</c> block into a person card.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="item">The list item; changed in place.</param>
|
||||||
|
/// <remarks>
|
||||||
|
/// The first paragraph is rebuilt from new inlines: raw HTML inlines for the
|
||||||
|
/// tags and literal inlines for the text, so Markdig escapes everything the
|
||||||
|
/// editor wrote. The note keeps its original inlines, so links and emphasis in it
|
||||||
|
/// still render. Any further blocks in the item stay untouched below.
|
||||||
|
/// </remarks>
|
||||||
|
internal static void ToCard(ListItemBlock item)
|
||||||
|
{
|
||||||
|
var attributes = item.GetAttributes();
|
||||||
|
attributes.AddClass(CardClass);
|
||||||
|
|
||||||
|
if (item.FirstOrDefault() is ParagraphBlock { Inline: not null } paragraph && Read(paragraph) is { } card)
|
||||||
|
{
|
||||||
|
foreach (var inline in card.Note)
|
||||||
|
{
|
||||||
|
inline.Remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
paragraph.Inline = card.Member.Head()
|
||||||
|
.Concat(card.Note.Length > 0 ? [Open("span", "team-note"), .. card.Note, Close("span")] : [])
|
||||||
|
.Aggregate(new ContainerInline(), (container, inline) => container.AppendChild(inline));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
attributes.AddClass(PlainCardClass);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether a <c>:::</c> block is a team block.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="container">The block to inspect.</param>
|
||||||
|
/// <returns><see langword="true"/> for <c>::: team</c>, with or without modifier.</returns>
|
||||||
|
internal static bool IsTeam(CustomContainer container) =>
|
||||||
|
string.Equals(container.Info, ContainerName, StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether a team block asks for the chair highlight (<c>::: team gross</c>).
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="container">The team block.</param>
|
||||||
|
/// <returns>
|
||||||
|
/// <see langword="true"/> when one of the words after the name is
|
||||||
|
/// <c>gross</c> or <c>groß</c>, in any case.
|
||||||
|
/// </returns>
|
||||||
|
internal static bool IsLarge(CustomContainer container) =>
|
||||||
|
(container.Arguments ?? "")
|
||||||
|
.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)
|
||||||
|
.Any(word => word.Equals(LargeModifier, StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| word.Equals("groß", StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The card's head: avatar, name, role chip and duty tags, as inlines.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>The inlines, in reading order; missing parts are left out.</returns>
|
||||||
|
/// <remarks>
|
||||||
|
/// The avatar is hidden from screen readers: it only repeats the name next to
|
||||||
|
/// it.
|
||||||
|
/// </remarks>
|
||||||
|
private Inline[] Head() =>
|
||||||
|
[
|
||||||
|
.. Initials is { Length: > 0 } initials
|
||||||
|
? [Open("span", $"team-avatar team-avatar-{Color.ToString(CultureInfo.InvariantCulture)}", " aria-hidden=\"true\""), new LiteralInline(initials), Close("span")]
|
||||||
|
: Array.Empty<Inline>(),
|
||||||
|
.. Element("team-name", Name, "strong"),
|
||||||
|
.. Role is { } role ? Element("team-role", role) : [],
|
||||||
|
.. Duties.Count > 0
|
||||||
|
? [Open("span", "team-duties"), .. Duties.SelectMany(Duty), Close("span")]
|
||||||
|
: Array.Empty<Inline>(),
|
||||||
|
];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The 32-bit FNV-1a hash of a text's UTF-16 code units.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="text">The text.</param>
|
||||||
|
/// <returns>The hash.</returns>
|
||||||
|
private static uint Fnv(string text) =>
|
||||||
|
unchecked(text.Aggregate(FnvOffset, (hash, letter) => (hash ^ letter) * FnvPrime));
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The MurmurHash3 finaliser (<c>fmix32</c>): spreads every input bit over all
|
||||||
|
/// output bits.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="hash">The hash to mix.</param>
|
||||||
|
/// <returns>The mixed hash.</returns>
|
||||||
|
private static uint Mix(uint hash) =>
|
||||||
|
unchecked(XorShift(XorShift(XorShift(hash, 16) * 0x85EBCA6B, 13) * 0xC2B2AE35, 16));
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Folds the high bits of a value into its low bits.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="value">The value.</param>
|
||||||
|
/// <param name="shift">How far to shift right before the XOR.</param>
|
||||||
|
/// <returns><paramref name="value"/> XOR <paramref name="value"/> shifted right.</returns>
|
||||||
|
private static uint XorShift(uint value, int shift) => value ^ (value >> shift);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One duty tag, led by a space so screen readers keep the tags apart.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="duty">The duty.</param>
|
||||||
|
/// <returns>The space and the tag.</returns>
|
||||||
|
private static Inline[] Duty(string duty) =>
|
||||||
|
[new LiteralInline(" "), .. Element("team-duty", duty)];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// An element with escaped text inside.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="classes">The value of the class attribute; written as is.</param>
|
||||||
|
/// <param name="text">The text; escaped by Markdig on rendering.</param>
|
||||||
|
/// <param name="tag">The element name.</param>
|
||||||
|
/// <returns>Opening tag, text and closing tag.</returns>
|
||||||
|
private static Inline[] Element(string classes, string text, string tag = "span") =>
|
||||||
|
[Open(tag, classes), new LiteralInline(text), Close(tag)];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// An opening tag with a class, as a raw HTML inline.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="tag">The element name.</param>
|
||||||
|
/// <param name="classes">The value of the class attribute; written as is.</param>
|
||||||
|
/// <param name="attributes">
|
||||||
|
/// Further attributes, written as is after the class, with a leading space.
|
||||||
|
/// </param>
|
||||||
|
/// <returns>The tag.</returns>
|
||||||
|
/// <remarks>
|
||||||
|
/// Only ever called with constants from this class, never with editor text:
|
||||||
|
/// nothing here is escaped.
|
||||||
|
/// </remarks>
|
||||||
|
private static HtmlInline Open(string tag, string classes, string attributes = "") =>
|
||||||
|
new($"<{tag} class=\"{classes}\"{attributes}>");
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A closing tag, as a raw HTML inline.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="tag">The element name.</param>
|
||||||
|
/// <returns>The tag.</returns>
|
||||||
|
private static HtmlInline Close(string tag) => new($"</{tag}>");
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The first letter of a word, upper case.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="word">One word of a name.</param>
|
||||||
|
/// <returns>The letter, or an empty string if the word has none.</returns>
|
||||||
|
private static string Initial(string word) =>
|
||||||
|
word.FirstOrDefault(char.IsLetter) is var letter and not '\0'
|
||||||
|
? letter.ToString().ToUpper(Cultures.German)
|
||||||
|
: "";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The text after the name, without separators and parentheses.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="inlines">The inlines after the bold name on the first line.</param>
|
||||||
|
/// <returns>The role, or <see langword="null"/> if nothing is left.</returns>
|
||||||
|
private static string? RoleOf(IEnumerable<Inline> inlines) =>
|
||||||
|
Markdown.PlainText(inlines).Trim(' ', ',', ':', ';', '-', '–') is var text
|
||||||
|
&& (text.StartsWith('(') && text.EndsWith(')') ? text[1..^1].Trim() : text) is { Length: > 0 } role
|
||||||
|
? role
|
||||||
|
: null;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Splits the italic duties line at its commas.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="duties">The italic span.</param>
|
||||||
|
/// <returns>The duties, trimmed, without empty entries.</returns>
|
||||||
|
private static string[] DutiesOf(EmphasisInline duties) =>
|
||||||
|
Markdown.PlainText(duties).Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The inlines of a line that carry content: no whitespace, no line break.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="line">One line from <see cref="Lines"/>.</param>
|
||||||
|
/// <returns>The content inlines, in order.</returns>
|
||||||
|
private static Inline[] Content(IEnumerable<Inline> line) =>
|
||||||
|
[.. line.Where(inline => !Markdown.IsBlank(inline))];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Splits a paragraph's inlines into lines.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="paragraph">The paragraph.</param>
|
||||||
|
/// <returns>
|
||||||
|
/// The lines; each keeps the line break that ends it, so the note can be moved
|
||||||
|
/// with its breaks intact.
|
||||||
|
/// </returns>
|
||||||
|
private static IEnumerable<List<Inline>> Lines(ParagraphBlock paragraph)
|
||||||
|
{
|
||||||
|
var line = new List<Inline>();
|
||||||
|
foreach (var inline in paragraph.Inline?.ToList() ?? [])
|
||||||
|
{
|
||||||
|
line.Add(inline);
|
||||||
|
if (inline is LineBreakInline)
|
||||||
|
{
|
||||||
|
yield return line;
|
||||||
|
line = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (line.Count > 0)
|
||||||
|
{
|
||||||
|
yield return line;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+524
-119
@@ -11,11 +11,30 @@
|
|||||||
--color-brand-dark: #1f5477;
|
--color-brand-dark: #1f5477;
|
||||||
--color-brand-tint: #eaf3f9;
|
--color-brand-tint: #eaf3f9;
|
||||||
|
|
||||||
/* The header bar's background: the IGMH blue at full strength. Its own token
|
/* The header bar's background: the IGMH blue, eased one step deeper. The
|
||||||
(not --color-brand) both because it only ever carries white text (so it needs
|
original #328fc6 carries white menu text at only 3.6:1, below AA for normal
|
||||||
no darkening) and because it must stay a deep blue in dark mode, where
|
text; #2a77a6 is the same hue at 4.9:1. Its own token (not --color-brand)
|
||||||
--color-brand is lightened for text/links. */
|
because it only ever carries white text and must stay a deep blue in dark
|
||||||
--color-header: #328fc6;
|
mode, where --color-brand is lightened for text/links. */
|
||||||
|
--color-header: #2a77a6;
|
||||||
|
|
||||||
|
/* Text and menu states on the header. White text, and two translucent dark
|
||||||
|
washes for the menu pills: they deepen whatever part of the gradient sits
|
||||||
|
behind a link, so one value works from the blue end to the green end and the
|
||||||
|
white text gains contrast instead of losing it (a white wash dropped the
|
||||||
|
active pill to 2.7:1). The active pill is the stronger one, so "where am I"
|
||||||
|
outranks "where is the mouse". */
|
||||||
|
--color-on-header: #fff;
|
||||||
|
--color-nav-hover: rgba(0, 0, 0, 0.12);
|
||||||
|
--color-nav-active: rgba(0, 0, 0, 0.2);
|
||||||
|
|
||||||
|
/* Footer: a dark brand blue, so the page visibly ends there. Light text in two
|
||||||
|
steps (headings and the name brighter, links softer); both stay >= 7:1 on the
|
||||||
|
band. The border token is a faint white line for the copyright rule. */
|
||||||
|
--color-footer: #1a3d57;
|
||||||
|
--color-on-footer: #f1f5f9;
|
||||||
|
--color-on-footer-soft: #c5d3df;
|
||||||
|
--color-footer-border: rgba(255, 255, 255, 0.15);
|
||||||
|
|
||||||
/* Accent for highlights (active nav, event dates, call-outs): the IGMH green.
|
/* Accent for highlights (active nav, event dates, call-outs): the IGMH green.
|
||||||
The -dark shade is darkened enough to keep small text and the FAQ marker
|
The -dark shade is darkened enough to keep small text and the FAQ marker
|
||||||
@@ -37,6 +56,11 @@
|
|||||||
not --color-accent, so white text stays readable across the whole sweep. */
|
not --color-accent, so white text stays readable across the whole sweep. */
|
||||||
--gradient-brand: linear-gradient(135deg, var(--color-header) 0%, #3a7a3e 100%);
|
--gradient-brand: linear-gradient(135deg, var(--color-header) 0%, #3a7a3e 100%);
|
||||||
|
|
||||||
|
/* The month band of the calendar sheet (CalendarSheet): the deep green of the
|
||||||
|
gradient's end, because it carries small white text -- on --color-accent that
|
||||||
|
would fall below AA. Same 5.2:1 as in the gradient. */
|
||||||
|
--color-calendar-band: #3a7a3e;
|
||||||
|
|
||||||
/* Buttons in editor content (a lone mail link, links in an "aufruf" box): a
|
/* Buttons in editor content (a lone mail link, links in an "aufruf" box): a
|
||||||
filled blue with white text. Own tokens, not --color-brand, because in dark
|
filled blue with white text. Own tokens, not --color-brand, because in dark
|
||||||
mode --color-brand turns light for link text, and white on that light blue
|
mode --color-brand turns light for link text, and white on that light blue
|
||||||
@@ -45,6 +69,34 @@
|
|||||||
--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;
|
||||||
|
|
||||||
|
/* Initials avatars on the person cards (::: team, see TeamMember.cs): only the
|
||||||
|
two brand colours, blue and green tint, so the board stays in the IGMH palette.
|
||||||
|
The colour is picked from the name, so it means nothing -- it only helps tell
|
||||||
|
the cards apart. The tints follow their tokens into dark mode; the green text
|
||||||
|
needs its own shade, because --color-accent-dark is too light for small text
|
||||||
|
on the tint (3.6:1). Blue 7.2:1, green 5.9:1. */
|
||||||
|
--color-avatar-1: var(--color-brand-tint);
|
||||||
|
--color-on-avatar-1: var(--color-brand-dark);
|
||||||
|
--color-avatar-2: var(--color-accent-tint);
|
||||||
|
--color-on-avatar-2: #2f6b33;
|
||||||
|
|
||||||
|
/* 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;
|
||||||
|
|
||||||
|
/* The focus ring on the dark brand surfaces (header, hero gradient, footer),
|
||||||
|
where the dark ring above would vanish: white in both schemes, >= 4.9:1 on
|
||||||
|
every one of them. */
|
||||||
|
--color-focus-inverse: #fff;
|
||||||
|
|
||||||
/* 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;
|
||||||
@@ -102,6 +154,28 @@
|
|||||||
because heavy letters at large sizes otherwise look spaced out. */
|
because heavy letters at large sizes otherwise look spaced out. */
|
||||||
--font-weight-heading: 800;
|
--font-weight-heading: 800;
|
||||||
--letter-spacing-heading: -0.015em;
|
--letter-spacing-heading: -0.015em;
|
||||||
|
|
||||||
|
/* WCAG AA check of the colour pairs, light / dark (computed, October 2026).
|
||||||
|
Text needs 4.5:1, large text and focus rings/graphics 3:1.
|
||||||
|
text on card / page ............... 15.9 / 6.2 14.6 / 7.2
|
||||||
|
soft text on card / page .......... 7.5 / 6.2 6.9 / 7.2
|
||||||
|
soft text on green / blue tint .... 6.8 / 5.6 6.6 / 5.7
|
||||||
|
link (brand) on card / page ....... 5.4 / 7.5 5.0 / 8.7
|
||||||
|
link on green / blue tint ......... 5.0 / 6.7 4.8 / 6.9
|
||||||
|
heading on card / page ............ 5.4 / 9.3 5.0 / 10.9
|
||||||
|
white on header / gradient green .. 4.9 / 5.5 5.2 / 6.4
|
||||||
|
white on menu pill hover / active . 6.0 / 6.6 6.9 / 7.6
|
||||||
|
footer heading / link on footer ... 10.4 / 10.5 7.4 / 6.9
|
||||||
|
white on button / button hover .... 5.4 / 5.5 8.1 / 8.1
|
||||||
|
light button text ................. 8.1 / 9.7 (hover 7.2 / 8.3)
|
||||||
|
white on calendar band ............ 5.2 / 6.4
|
||||||
|
avatar blue / green ............... 7.2 / 8.6 5.9 / 6.7
|
||||||
|
key figure / caption on card ...... 5.4 / 7.5 5.0 / 6.9
|
||||||
|
focus ring on page / card ......... 7.5 / 11.0 8.1 / 9.4
|
||||||
|
white ring on header / footer ..... 4.9 / 5.5 11.4 / 16.6
|
||||||
|
green markers on card (graphic) ... 3.8 / 7.5
|
||||||
|
All pass. Exempt as pure decoration: the light green --color-accent (card
|
||||||
|
stripes, heading bars, the active-menu stroke), which never carries text. */
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Dark mode: follows the visitor's OS setting automatically (prefers-color-scheme).
|
/* Dark mode: follows the visitor's OS setting automatically (prefers-color-scheme).
|
||||||
@@ -119,7 +193,14 @@
|
|||||||
--color-brand-dark: #a3d5f4; /* hover goes lighter, not darker, on dark */
|
--color-brand-dark: #a3d5f4; /* hover goes lighter, not darker, on dark */
|
||||||
--color-brand-tint: #223038;
|
--color-brand-tint: #223038;
|
||||||
|
|
||||||
--color-header: #266f9c; /* the IGMH blue eased down, to sit calmly on dark */
|
--color-header: #266f9c; /* the IGMH blue eased down, to sit calmly on
|
||||||
|
dark; white on it 5.5:1 */
|
||||||
|
|
||||||
|
--color-footer: #10202e; /* a deep night blue: darker than the page, so
|
||||||
|
the band still reads as the page's end */
|
||||||
|
--color-on-footer: #c9ced6; /* the heading tone, 10:1 on the band */
|
||||||
|
--color-on-footer-soft: #a2a8b2; /* the body text tone, 6.9:1 on the band */
|
||||||
|
--color-footer-border: rgba(255, 255, 255, 0.1);
|
||||||
|
|
||||||
--color-accent: #86c97d; /* the IGMH green nudged brighter for dark */
|
--color-accent: #86c97d; /* the IGMH green nudged brighter for dark */
|
||||||
--color-accent-dark: #86c97d; /* no darkening needed on a dark surface */
|
--color-accent-dark: #86c97d; /* no darkening needed on a dark surface */
|
||||||
@@ -130,11 +211,23 @@
|
|||||||
yellow would glare on the dark page */
|
yellow would glare on the dark page */
|
||||||
|
|
||||||
--gradient-brand: linear-gradient(135deg, var(--color-header) 0%, #2f6b33 100%);
|
--gradient-brand: linear-gradient(135deg, var(--color-header) 0%, #2f6b33 100%);
|
||||||
|
--color-calendar-band: #2f6b33; /* the gradient's dark-mode green, 6.4:1 with white */
|
||||||
|
|
||||||
--color-button: #266f9c; /* the header blue: deep enough for white
|
--color-button: #266f9c; /* the header blue: deep enough for white
|
||||||
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 */
|
||||||
|
|
||||||
|
/* Avatar green text: the brightened accent, 6.7:1 on the dark green tint
|
||||||
|
(the blue pair follows its tokens, 8.6:1). */
|
||||||
|
--color-on-avatar-2: var(--color-accent);
|
||||||
|
|
||||||
|
--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 */
|
||||||
|
|
||||||
@@ -188,6 +281,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 {
|
||||||
@@ -242,6 +340,76 @@ h1:focus {
|
|||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Keyboard focus, one look for the whole site: a thick ring in --color-focus with a
|
||||||
|
little air between ring and element, so it never melts into a card's border or a
|
||||||
|
button's fill. :focus-visible, not :focus -- the ring shows for keyboard users
|
||||||
|
and stays away after a mouse click. Covers every focusable thing a page has:
|
||||||
|
links (and with them every card, which is one big link), buttons, the <summary>
|
||||||
|
of each disclosure, form fields and anything made focusable with tabindex. Dark
|
||||||
|
brand surfaces (header, hero, footer) switch the colour to
|
||||||
|
--color-focus-inverse.
|
||||||
|
tabindex="-1" is left out: such an element is never reached with Tab, only
|
||||||
|
focused by code -- the h1 by FocusOnNavigate on every page load, <main> by the
|
||||||
|
skip link. Chromium counts that as :focus-visible, and since this rule outweighs
|
||||||
|
h1:focus / main:focus below, the page title would otherwise get a ring. */
|
||||||
|
:is(a, button, summary, input, select, textarea, [tabindex]:not([tabindex="-1"])):focus-visible {
|
||||||
|
outline: 3px solid var(--color-focus);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* <main> takes focus from the skip link (tabindex="-1") only so the next Tab
|
||||||
|
continues inside the content; a ring around the whole page would just be noise. */
|
||||||
|
main:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Skip link: the first thing on every page, so keyboard and screen-reader users
|
||||||
|
can jump past the header menu straight to the content. Off screen until it gets
|
||||||
|
focus, then it drops in at the top left over the sticky header -- a link that
|
||||||
|
is only hidden visually stays in the tab order, display:none would remove it. */
|
||||||
|
.skip-link {
|
||||||
|
position: absolute;
|
||||||
|
top: var(--space-2);
|
||||||
|
left: var(--space-2);
|
||||||
|
z-index: 100;
|
||||||
|
padding: var(--space-2) var(--space-4);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--color-button-light);
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
color: var(--color-on-button-light);
|
||||||
|
font-weight: 700;
|
||||||
|
text-decoration: none;
|
||||||
|
transform: translateY(-200%);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* It lands on the header, so it takes the light button look and the white ring
|
||||||
|
of the dark brand surfaces. Shown instantly, no transition: it is a jump aid,
|
||||||
|
not an effect. */
|
||||||
|
.skip-link:focus {
|
||||||
|
transform: none;
|
||||||
|
outline-color: var(--color-focus-inverse);
|
||||||
|
}
|
||||||
|
|
||||||
|
.skip-link:hover {
|
||||||
|
color: var(--color-on-button-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Text for screen readers only: off screen visually, still read aloud. For places
|
||||||
|
where the visible form is decoration (the calendar sheet) and the spoken form
|
||||||
|
must carry the full information. Not display:none -- that hides it from screen
|
||||||
|
readers too. */
|
||||||
|
.visually-hidden {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
margin: -1px;
|
||||||
|
padding: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
clip-path: inset(50%);
|
||||||
|
white-space: nowrap;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
/* Icons (Shared/Icon.razor) sit inline next to text: nudged down to line up with the
|
/* Icons (Shared/Icon.razor) sit inline next to text: nudged down to line up with the
|
||||||
letters instead of the baseline, and never squeezed when they share a flex row
|
letters instead of the baseline, and never squeezed when they share a flex row
|
||||||
with a long label. */
|
with a long label. */
|
||||||
@@ -250,49 +418,130 @@ 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-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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Quiet = no frame at all, just the brand-blue label; hover lays the blue tint
|
||||||
|
behind it. For a way back ("Alle Beiträge") that should be easy to find but not
|
||||||
|
compete with the page's content. The negative inline margin lines the label up
|
||||||
|
with the text below while the hover tint still gets its padding. */
|
||||||
|
.btn-quiet {
|
||||||
|
margin-inline: calc(-1 * var(--space-3));
|
||||||
|
padding-inline: var(--space-3);
|
||||||
|
color: var(--color-brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-quiet: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
|
||||||
global link colour (dark blue) and become unreadable on the blue header bar. */
|
global link colour (dark blue) and become unreadable on the blue header bar.
|
||||||
|
Every link is a pill with the same padding whether active or not, so switching
|
||||||
|
pages never shifts the row. Full white, not a softened white: the blue end of
|
||||||
|
the gradient already leaves little contrast headroom. */
|
||||||
.main-nav a {
|
.main-nav a {
|
||||||
color: rgba(255, 255, 255, 0.85);
|
position: relative;
|
||||||
|
padding: var(--space-1) var(--space-3);
|
||||||
|
border-radius: 999px;
|
||||||
|
color: var(--color-on-header);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
padding: var(--space-1) 0;
|
transition: background-color var(--transition);
|
||||||
border-bottom: 2px solid transparent;
|
|
||||||
transition: color var(--transition), border-color var(--transition);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.main-nav a:hover {
|
.main-nav a:hover {
|
||||||
color: #fff;
|
color: var(--color-on-header);
|
||||||
|
background: var(--color-nav-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* NavLink adds .active to the current route's link; a warm accent underline marks
|
/* The dark default ring would vanish on the blue header (2.3:1); white stands out
|
||||||
where the visitor is on the blue bar. */
|
on the whole gradient. */
|
||||||
|
.main-nav a:focus-visible {
|
||||||
|
outline-color: var(--color-focus-inverse);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* NavLink adds .active to the current route's link: a stronger pill plus a short
|
||||||
|
green stroke under the text mark where the visitor is. The stroke is drawn by
|
||||||
|
::after, not a border, so it follows the text width inside the rounded pill. */
|
||||||
.main-nav a.active {
|
.main-nav a.active {
|
||||||
color: #fff;
|
background: var(--color-nav-active);
|
||||||
border-bottom-color: var(--color-accent);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Mobile: stacked links inside the collapsed menu get row separators. Same global
|
.main-nav a.active::after {
|
||||||
placement, same NavLink reason as above. Scoped to the narrow layout via the
|
content: "";
|
||||||
media query so it does not affect the desktop row. */
|
position: absolute;
|
||||||
|
left: var(--space-3);
|
||||||
|
right: var(--space-3);
|
||||||
|
bottom: 0.2rem;
|
||||||
|
height: 2px;
|
||||||
|
border-radius: 1px;
|
||||||
|
background: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile: the open menu stacks the links centred, each as wide as its text -- so
|
||||||
|
they stay the same pills as on the desktop, with the stroke under the whole word.
|
||||||
|
Only taller, for a comfortable tap target; the stroke moves up with the extra
|
||||||
|
padding so it still sits right under the text, not at the pill's edge. Same
|
||||||
|
global placement, same NavLink reason as above. */
|
||||||
@media (max-width: 40rem) {
|
@media (max-width: 40rem) {
|
||||||
.main-nav a {
|
.main-nav a {
|
||||||
padding: 0.6rem 0;
|
padding: 0.6rem var(--space-3);
|
||||||
border-bottom: 1px solid rgba(255, 255, 255, 0.15);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.main-nav a.active {
|
.main-nav a.active::after {
|
||||||
border-bottom-color: rgba(255, 255, 255, 0.15);
|
bottom: 0.55rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --- Shared card styles for content lists --------------------------------------
|
/* --- Lists of post and event cards ------------------------------------------------
|
||||||
Posts, events and FAQs appear both on their own page (PostList/EventList/
|
Posts and events appear both on their own page (PostList/EventList) AND embedded
|
||||||
FaqList) AND embedded on the home page (the *Embed components), with the same
|
on the home page (the *Embed components). The cards themselves are components
|
||||||
class names. These rules live here (global) so both places share one look
|
(PostCard, EventCard) with their own scoped styles; only the list around them is
|
||||||
instead of duplicating the CSS per component. The card look: a white surface
|
shared here: no bullets, one column with even gaps. A page may widen the grid
|
||||||
lifted off the tinted page background with a soft shadow and a gentle hover. */
|
(the posts page goes to two columns). */
|
||||||
|
|
||||||
.post-list,
|
.post-list,
|
||||||
.event-list {
|
.event-list {
|
||||||
@@ -303,29 +552,6 @@ h1:focus {
|
|||||||
gap: var(--space-3);
|
gap: var(--space-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.post-list li,
|
|
||||||
.event-list li {
|
|
||||||
background: var(--color-bg);
|
|
||||||
border-radius: var(--radius);
|
|
||||||
box-shadow: var(--shadow);
|
|
||||||
transition: box-shadow var(--transition);
|
|
||||||
}
|
|
||||||
|
|
||||||
.post-list li:hover,
|
|
||||||
.event-list li:hover {
|
|
||||||
box-shadow: var(--shadow-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* The whole card is one link. The <a> fills the card and carries the padding (so
|
|
||||||
the entire surface is clickable, not just the text), and resets link colour and
|
|
||||||
underline so the content inside -- title, date, location -- keeps its own look. */
|
|
||||||
.card-link {
|
|
||||||
display: block;
|
|
||||||
padding: var(--space-3) var(--space-4);
|
|
||||||
color: inherit;
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* FAQ topics: each is a collapsible card. Its summary is the topic heading; open it
|
/* FAQ topics: each is a collapsible card. Its summary is the topic heading; open it
|
||||||
to reveal the optional intro and the questions (themselves collapsible .faq-item
|
to reveal the optional intro and the questions (themselves collapsible .faq-item
|
||||||
cards). The topic marker is a rotating triangle, distinct from the +/- on the
|
cards). The topic marker is a rotating triangle, distinct from the +/- on the
|
||||||
@@ -343,7 +569,10 @@ h1:focus {
|
|||||||
box-shadow: var(--shadow-hover);
|
box-shadow: var(--shadow-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.faq-topic > summary {
|
/* A <details> with class "disclosure": the browser's own marker is replaced by a
|
||||||
|
triangle that turns down when open. Shared by the FAQ topics and the past events,
|
||||||
|
so "click to open" looks the same everywhere. */
|
||||||
|
.disclosure > summary {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 1.15rem;
|
font-size: 1.15rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -354,17 +583,17 @@ h1:focus {
|
|||||||
gap: var(--space-2);
|
gap: var(--space-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.faq-topic > summary::-webkit-details-marker {
|
.disclosure > summary::-webkit-details-marker {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.faq-topic > summary::before {
|
.disclosure > summary::before {
|
||||||
content: "▸";
|
content: "▸";
|
||||||
color: var(--color-accent-dark);
|
color: var(--color-accent-dark);
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.faq-topic[open] > summary::before {
|
.disclosure[open] > summary::before {
|
||||||
content: "▾";
|
content: "▾";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -378,57 +607,6 @@ h1:focus {
|
|||||||
color: var(--color-text-soft);
|
color: var(--color-text-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Post and event cards are built the same way, so they read as one style on the
|
|
||||||
home page where both appear: the title leads in the brand colour (the whole card
|
|
||||||
is the link, so the title itself is plain text), the date sits quietly beneath. */
|
|
||||||
.post-title,
|
|
||||||
.event-title {
|
|
||||||
display: block;
|
|
||||||
font-size: 1.15rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--color-brand);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* The date's small, softened look comes from PostDate.razor.css / EventDate; here
|
|
||||||
only the shared spacing: nudge it down from the title above. */
|
|
||||||
.post-list time,
|
|
||||||
.event-list time {
|
|
||||||
margin-top: var(--space-1);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* The location sits on its own quiet line beneath the date. */
|
|
||||||
.event-location {
|
|
||||||
display: block;
|
|
||||||
margin-top: var(--space-1);
|
|
||||||
color: var(--color-text-soft);
|
|
||||||
}
|
|
||||||
|
|
||||||
.event-note {
|
|
||||||
margin: var(--space-2) 0 0;
|
|
||||||
color: var(--color-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Past events (only on the full events page): same cards, quieter and flat. */
|
|
||||||
.event-list-past li {
|
|
||||||
box-shadow: none;
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
background: var(--color-surface);
|
|
||||||
}
|
|
||||||
|
|
||||||
.event-list-past li:hover {
|
|
||||||
box-shadow: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.event-list-past time {
|
|
||||||
color: var(--color-text-soft);
|
|
||||||
font-weight: 400;
|
|
||||||
}
|
|
||||||
|
|
||||||
.event-list-past .event-title {
|
|
||||||
font-weight: 400;
|
|
||||||
color: var(--color-text-soft);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* FAQ: each question is an expandable card with an accent +/– marker. */
|
/* FAQ: each question is an expandable card with an accent +/– marker. */
|
||||||
.faq-item {
|
.faq-item {
|
||||||
background: var(--color-bg);
|
background: var(--color-bg);
|
||||||
@@ -600,12 +778,15 @@ h1:focus {
|
|||||||
this is what turns a plain list of documents on /downloads into a download
|
this is what turns a plain list of documents on /downloads into a download
|
||||||
area. Markdown.cs marks such paragraphs/items .lone-link, because CSS cannot see
|
area. Markdown.cs marks such paragraphs/items .lone-link, because CSS cannot see
|
||||||
the text around a link. Two child paths: in a tight list the link sits directly
|
the text around a link. Two child paths: in a tight list the link sits directly
|
||||||
in the <li>, in a loose list inside a <p>. */
|
in the <li>, in a loose list inside a <p>. border-box: the 42rem include the
|
||||||
|
padding, so the card ends flush with the running text instead of overhanging
|
||||||
|
it (same for the aufruf and hinweis boxes below). */
|
||||||
.markdown-body .lone-link > a[href$=".pdf" i],
|
.markdown-body .lone-link > a[href$=".pdf" i],
|
||||||
.markdown-body .lone-link > p > a[href$=".pdf" i] {
|
.markdown-body .lone-link > p > a[href$=".pdf" i] {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--space-3);
|
gap: var(--space-3);
|
||||||
|
box-sizing: border-box;
|
||||||
max-width: 42rem;
|
max-width: 42rem;
|
||||||
padding: var(--space-3) var(--space-4);
|
padding: var(--space-3) var(--space-4);
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
@@ -692,6 +873,7 @@ h1:focus {
|
|||||||
/* ::: aufruf -- a highlighted call to action: a tinted box with a brand-blue edge.
|
/* ::: aufruf -- a highlighted call to action: a tinted box with a brand-blue edge.
|
||||||
Its links become buttons (rule above). */
|
Its links become buttons (rule above). */
|
||||||
.markdown-body .aufruf {
|
.markdown-body .aufruf {
|
||||||
|
box-sizing: border-box; /* padding inside the 42rem, flush with the text */
|
||||||
margin: 0 0 var(--space-4);
|
margin: 0 0 var(--space-4);
|
||||||
padding: var(--space-4);
|
padding: var(--space-4);
|
||||||
max-width: 42rem;
|
max-width: 42rem;
|
||||||
@@ -705,6 +887,7 @@ h1:focus {
|
|||||||
says what matters. */
|
says what matters. */
|
||||||
.markdown-body .hinweis {
|
.markdown-body .hinweis {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
box-sizing: border-box; /* padding inside the 42rem, flush with the text */
|
||||||
margin: 0 0 var(--space-4);
|
margin: 0 0 var(--space-4);
|
||||||
padding: var(--space-3) var(--space-4) var(--space-3) 3.25rem;
|
padding: var(--space-3) var(--space-4) var(--space-3) 3.25rem;
|
||||||
max-width: 42rem;
|
max-width: 42rem;
|
||||||
@@ -723,24 +906,39 @@ h1:focus {
|
|||||||
mask: var(--icon-info) center / contain no-repeat;
|
mask: var(--icon-info) center / contain no-repeat;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ::: kacheln and ::: kennzahlen -- a list laid out as a grid of cards. auto-fill
|
/* ::: kacheln and ::: kennzahlen -- a list laid out as a grid of cards. They keep
|
||||||
with a minimum width gives as many columns as fit (one on a phone) without any
|
the 42rem reading column like the text and the other boxes: one calm left and
|
||||||
breakpoint. */
|
right edge down the page instead of blocks of three different widths. */
|
||||||
.markdown-body :is(.kacheln, .kennzahlen) > ul {
|
.markdown-body :is(.kacheln, .kennzahlen) > ul {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: var(--space-3);
|
gap: var(--space-3);
|
||||||
max-width: none;
|
box-sizing: border-box;
|
||||||
|
max-width: 42rem;
|
||||||
margin: 0 0 var(--space-4);
|
margin: 0 0 var(--space-4);
|
||||||
padding: 0;
|
padding: 0;
|
||||||
list-style: none;
|
list-style: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Kacheln: auto-fit with a 15rem minimum gives exactly two columns in the 42rem
|
||||||
|
(three would need 46rem), so four tiles sit as 2 x 2. Below ~31rem it drops to
|
||||||
|
one column -- two would squeeze a long word like "Chancengerechtigkeit" out of
|
||||||
|
its card. min() keeps a single card from overflowing a very narrow screen. */
|
||||||
.markdown-body .kacheln > ul {
|
.markdown-body .kacheln > ul {
|
||||||
grid-template-columns: repeat(auto-fill, minmax(14rem, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(min(100%, 15rem), 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Kennzahlen: one row of equal columns where there is room. auto-fill used to
|
||||||
|
leave empty tracks on the right on a wide screen and one figure alone in a second
|
||||||
|
row on a phone. A narrow column switches to stacked rows instead (below). The
|
||||||
|
block is its own container, so the switch follows the width the figures actually
|
||||||
|
get, not the screen. */
|
||||||
|
.markdown-body .kennzahlen {
|
||||||
|
container-type: inline-size;
|
||||||
}
|
}
|
||||||
|
|
||||||
.markdown-body .kennzahlen > ul {
|
.markdown-body .kennzahlen > ul {
|
||||||
grid-template-columns: repeat(auto-fill, minmax(10rem, 1fr));
|
grid-auto-flow: column;
|
||||||
|
grid-auto-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The grid gap does the spacing; the list's own item spacing would only push the
|
/* The grid gap does the spacing; the list's own item spacing would only push the
|
||||||
@@ -764,24 +962,215 @@ h1:focus {
|
|||||||
color: var(--color-heading);
|
color: var(--color-heading);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* On a phone the tiles keep their card but tighten up, so four of them stacked
|
||||||
|
do not turn into a long scroll. */
|
||||||
|
@media (max-width: 40rem) {
|
||||||
|
.markdown-body .kacheln > ul {
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-body .kacheln li {
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-body .kacheln li > strong:first-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* Kennzahlen: "- **seit 1975** Gründung" -- the bold part becomes a big figure,
|
/* Kennzahlen: "- **seit 1975** Gründung" -- the bold part becomes a big figure,
|
||||||
the rest a small caption beneath. Brand blue for the figure, not the green: the
|
the rest a small caption beneath. A white card with a green stroke on top, not
|
||||||
green does not reach 3:1 on its own tint, the blue does. */
|
a green wash: a ::: hinweis often follows right below, and two green tints in a
|
||||||
|
row ran together into one green patch. Brand blue for the figure: 5.4:1 on the
|
||||||
|
card (5.0 dark), where the green would not reach 3:1. */
|
||||||
.markdown-body .kennzahlen li {
|
.markdown-body .kennzahlen li {
|
||||||
padding: var(--space-4) var(--space-3);
|
padding: var(--space-4) var(--space-2);
|
||||||
|
border-top: 4px solid var(--color-accent);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
background: var(--color-accent-tint);
|
background: var(--color-bg);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
color: var(--color-text-soft);
|
color: var(--color-text-soft);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The figure scales with the block (cqi, not vw): 2rem in the full 42rem, a little
|
||||||
|
less just above the switch to rows. nowrap: "seit 1975" split over two lines
|
||||||
|
reads as two figures. */
|
||||||
.markdown-body .kennzahlen li > strong:first-child {
|
.markdown-body .kennzahlen li > strong:first-child {
|
||||||
display: block;
|
display: block;
|
||||||
color: var(--color-brand);
|
color: var(--color-brand);
|
||||||
font-size: 2rem;
|
font-size: clamp(1.5rem, 4.75cqi, 2rem);
|
||||||
font-weight: var(--font-weight-heading);
|
font-weight: var(--font-weight-heading);
|
||||||
letter-spacing: var(--letter-spacing-heading);
|
letter-spacing: var(--letter-spacing-heading);
|
||||||
line-height: 1.15;
|
line-height: 1.15;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Below 30rem three columns get under 10rem each: the figure no longer fits on one
|
||||||
|
line and a caption like "im Einsatz für die IGMH" stacks up word by word. So the
|
||||||
|
figures become rows of one card -- figure left, caption beside it, a thin line
|
||||||
|
between rows. Subgrid gives every row the same figure column, so the captions
|
||||||
|
line up whatever the figure's length. */
|
||||||
|
@container (max-width: 30rem) {
|
||||||
|
.markdown-body .kennzahlen > ul {
|
||||||
|
grid-auto-flow: row;
|
||||||
|
grid-template-columns: max-content 1fr;
|
||||||
|
gap: 0;
|
||||||
|
border-top: 4px solid var(--color-accent);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--color-bg);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-body .kennzahlen li {
|
||||||
|
display: grid;
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
grid-template-columns: subgrid;
|
||||||
|
column-gap: var(--space-3);
|
||||||
|
align-items: baseline;
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
border-top: none;
|
||||||
|
border-radius: 0;
|
||||||
|
background: none;
|
||||||
|
box-shadow: none;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-body .kennzahlen li + li {
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-body .kennzahlen li > strong:first-child {
|
||||||
|
font-size: 1.35rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ::: team -- one person card per list item (Markdown.cs/TeamMember.cs build the
|
||||||
|
avatar, name, role chip, duty tags and note). Each "## Vorsitz" etc. above stays
|
||||||
|
a normal heading; every group is a block of its own. One raster for all of them,
|
||||||
|
so the cards line up across groups: a group of one person gets the same card
|
||||||
|
width as a group of six, not a stretched one. 17rem fits a long double name.
|
||||||
|
Like every other block it stays in the 42rem reading column, so the cards share
|
||||||
|
the edges of the text above and below them; that leaves room for two columns
|
||||||
|
(three would need 52.5rem), one on a phone.
|
||||||
|
The cards of a row share one height (the grid's default stretch), so a row reads
|
||||||
|
as a calm band; the card itself keeps its content at the top (align-content). */
|
||||||
|
.markdown-body .team > ul {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(17rem, 1fr));
|
||||||
|
gap: var(--space-3);
|
||||||
|
box-sizing: border-box;
|
||||||
|
max-width: 42rem;
|
||||||
|
margin: 0 0 var(--space-4);
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-body .team li + li {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The card: avatar in a column of its own, everything else stacked beside it.
|
||||||
|
Hover only deepens the shadow -- nothing moves. */
|
||||||
|
.markdown-body .team-card {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr;
|
||||||
|
column-gap: var(--space-3);
|
||||||
|
align-content: start;
|
||||||
|
padding: var(--space-3) var(--space-4);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--color-bg);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
transition: box-shadow var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-body .team-card:hover {
|
||||||
|
box-shadow: var(--shadow-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* In a loose list (blank lines between items) Markdig wraps the card's text in a
|
||||||
|
<p>; display: contents lets its parts join the card's grid all the same. */
|
||||||
|
.markdown-body .team-card > p:first-child {
|
||||||
|
display: contents;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Everything but the avatar goes into the text column -- including whatever else
|
||||||
|
an editor puts into the item (a second paragraph, a sub-list). */
|
||||||
|
.markdown-body .team-card > :not(.team-avatar),
|
||||||
|
.markdown-body .team-card > p:first-child > :not(.team-avatar) {
|
||||||
|
grid-column: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A card without the convention (no bold name) is just its text. */
|
||||||
|
.markdown-body .team-card-plain {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-body .team-avatar {
|
||||||
|
grid-row: 1 / span 4;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 3rem;
|
||||||
|
height: 3rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
font-weight: var(--font-weight-heading);
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-body .team-avatar-1 { background: var(--color-avatar-1); color: var(--color-on-avatar-1); }
|
||||||
|
.markdown-body .team-avatar-2 { background: var(--color-avatar-2); color: var(--color-on-avatar-2); }
|
||||||
|
|
||||||
|
.markdown-body .team-name {
|
||||||
|
color: var(--color-heading);
|
||||||
|
font-size: 1.15rem;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The role as a chip under the name; justify-self keeps it as wide as its text,
|
||||||
|
not the column. */
|
||||||
|
.markdown-body .team-role {
|
||||||
|
justify-self: start;
|
||||||
|
margin-top: var(--space-1);
|
||||||
|
padding: 0.1rem var(--space-2);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--color-brand-tint);
|
||||||
|
color: var(--color-brand-dark);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Duties as small outlined tags: quieter than the role chip, because the role
|
||||||
|
says who someone is and the duties only what they look after. A tag never breaks
|
||||||
|
inside ("Stellvertretende Schulkonferenz" stays one piece); the row wraps between
|
||||||
|
tags instead. */
|
||||||
|
.markdown-body .team-duties {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-1);
|
||||||
|
margin-top: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-body .team-duty {
|
||||||
|
white-space: nowrap;
|
||||||
|
padding: 0.05rem var(--space-2);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--color-text-soft);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-body .team-note {
|
||||||
|
margin-top: var(--space-2);
|
||||||
|
color: var(--color-text-soft);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ::: team gross -- the chair. Same raster and card as every other group, so the
|
||||||
|
page keeps one rhythm; only the role chip stands out, filled in the button blue
|
||||||
|
instead of the pale tint (white on it 5.4:1, in dark mode too). */
|
||||||
|
.markdown-body .team.gross .team-role {
|
||||||
|
background: var(--color-button);
|
||||||
|
color: var(--color-on-button);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Tables (pipe tables): not in the content yet, styled ahead of time. As a block
|
/* Tables (pipe tables): not in the content yet, styled ahead of time. As a block
|
||||||
@@ -821,3 +1210,19 @@ h1:focus {
|
|||||||
border: none;
|
border: none;
|
||||||
border-top: 1px solid var(--color-border);
|
border-top: 1px solid var(--color-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Reduced motion: visitors who asked their system for less movement get none.
|
||||||
|
The site has no animations, only short transitions (hover colours, the
|
||||||
|
hamburger turning into an X, the mobile menu folding open); these are cut to
|
||||||
|
an instant here. Near zero rather than none, so anything that waits for a
|
||||||
|
transition to end still gets its event. */
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
*,
|
||||||
|
::before,
|
||||||
|
::after {
|
||||||
|
transition-duration: 0.01ms !important;
|
||||||
|
animation-duration: 0.01ms !important;
|
||||||
|
animation-iteration-count: 1 !important;
|
||||||
|
scroll-behavior: auto !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<!--
|
||||||
|
Static maintenance page shown when PocketBase is unreachable. The gate in Program.cs
|
||||||
|
serves this file with status 503 before any component renders, so it must stand on
|
||||||
|
its own: no menu, no PocketBase, no script, and its styles are inlined rather than
|
||||||
|
linked to the fingerprinted app.css (whose hashed name this static file cannot know).
|
||||||
|
The tokens below are a copy of the ones this page needs from app.css, light and dark,
|
||||||
|
under the same names -- when a colour changes there, change it here too. The header
|
||||||
|
band and the signet mirror MainLayout and Signet.razor, so the page still looks like
|
||||||
|
the site. The font is our own self-hosted Nunito; static files stay reachable while
|
||||||
|
PocketBase is down, the gate only covers pages.
|
||||||
|
-->
|
||||||
|
<html lang="de">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8"/>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||||
|
<meta name="robots" content="noindex"/>
|
||||||
|
<title>Elternbeirat der IGMH – gerade nicht erreichbar</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--color-brand: #2b6f9c;
|
||||||
|
--color-brand-dark: #1f5477;
|
||||||
|
--color-header: #2a77a6;
|
||||||
|
--color-on-header: #fff;
|
||||||
|
--color-accent-dark: #4f9247;
|
||||||
|
--gradient-brand: linear-gradient(135deg, var(--color-header) 0%, #3a7a3e 100%);
|
||||||
|
--color-focus: #1f5477;
|
||||||
|
--color-heading: #2b6f9c;
|
||||||
|
--color-text: #222;
|
||||||
|
--color-text-soft: #555;
|
||||||
|
--color-bg: #fff;
|
||||||
|
--color-page: #f4f6f4;
|
||||||
|
--color-border: #e0e0e0;
|
||||||
|
|
||||||
|
--space-2: 0.5rem;
|
||||||
|
--space-3: 1rem;
|
||||||
|
--space-4: 1.5rem;
|
||||||
|
--space-5: 2rem;
|
||||||
|
--radius: 12px;
|
||||||
|
--shadow: 0 1px 3px rgba(31, 58, 95, 0.08), 0 4px 12px rgba(31, 58, 95, 0.06);
|
||||||
|
|
||||||
|
--font-family: "Nunito", system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||||
|
--font-size-body: 1.0625rem;
|
||||||
|
--font-weight-heading: 800;
|
||||||
|
--letter-spacing-heading: -0.015em;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
--color-brand: #7cc0ec;
|
||||||
|
--color-brand-dark: #a3d5f4;
|
||||||
|
--color-header: #266f9c;
|
||||||
|
--color-accent-dark: #86c97d;
|
||||||
|
--gradient-brand: linear-gradient(135deg, var(--color-header) 0%, #2f6b33 100%);
|
||||||
|
--color-focus: #a3d5f4;
|
||||||
|
--color-heading: #c9ced6;
|
||||||
|
--color-text: #a2a8b2;
|
||||||
|
--color-text-soft: #a2a8b2;
|
||||||
|
--color-bg: #222834;
|
||||||
|
--color-page: #171b22;
|
||||||
|
--color-border: #333b47;
|
||||||
|
--shadow: 0 1px 3px rgba(0, 0, 0, 0.4), 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Absolute path: this file is served for any page URL, e.g. /posts/new-board,
|
||||||
|
where a relative "fonts/..." would point into /posts/. */
|
||||||
|
@font-face {
|
||||||
|
font-family: "Nunito";
|
||||||
|
src: url("/fonts/nunito-latin-wght.woff2") format("woff2");
|
||||||
|
font-weight: 400 800;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
font-family: var(--font-family);
|
||||||
|
line-height: 1.65;
|
||||||
|
color-scheme: light dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100dvh;
|
||||||
|
background: var(--color-page);
|
||||||
|
color: var(--color-text);
|
||||||
|
font-size: var(--font-size-body);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The header band of MainLayout without the menu: there is nothing to
|
||||||
|
navigate to while the pages are down. */
|
||||||
|
header {
|
||||||
|
background: var(--gradient-brand);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-inner {
|
||||||
|
max-width: 72rem;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: var(--space-3) 1.25rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
color: var(--color-on-header);
|
||||||
|
}
|
||||||
|
|
||||||
|
.signet {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 2.5rem;
|
||||||
|
height: 2.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signet-bubble {
|
||||||
|
fill: var(--color-on-header);
|
||||||
|
}
|
||||||
|
|
||||||
|
.signet-figures {
|
||||||
|
fill: var(--color-accent-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-text {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
line-height: 1.15;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-name {
|
||||||
|
font-size: 1.35rem;
|
||||||
|
font-weight: var(--font-weight-heading);
|
||||||
|
letter-spacing: var(--letter-spacing-heading);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-school {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
box-sizing: border-box;
|
||||||
|
max-width: 36rem;
|
||||||
|
margin: var(--space-5) auto;
|
||||||
|
padding: 0 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: var(--color-bg);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
padding: var(--space-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin: 0 0 var(--space-3);
|
||||||
|
color: var(--color-heading);
|
||||||
|
font-size: 1.5rem;
|
||||||
|
line-height: 1.25;
|
||||||
|
font-weight: var(--font-weight-heading);
|
||||||
|
letter-spacing: var(--letter-spacing-heading);
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: var(--color-brand);
|
||||||
|
font-weight: 600;
|
||||||
|
text-underline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:hover {
|
||||||
|
color: var(--color-brand-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
a:focus-visible {
|
||||||
|
outline: 3px solid var(--color-focus);
|
||||||
|
outline-offset: 2px;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact {
|
||||||
|
margin-top: var(--space-4);
|
||||||
|
padding-top: var(--space-4);
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
color: var(--color-text-soft);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<div class="header-inner">
|
||||||
|
<!-- Not a link: the start page is down as well, it would only lead here. -->
|
||||||
|
<svg class="signet" viewBox="0 0 40 40" width="40" height="40" aria-hidden="true" focusable="false">
|
||||||
|
<path class="signet-bubble"
|
||||||
|
d="M8 4h24a6 6 0 0 1 6 6v15a6 6 0 0 1-6 6H18l-8 6v-6H8a6 6 0 0 1-6-6V10a6 6 0 0 1 6-6z"/>
|
||||||
|
<g class="signet-figures">
|
||||||
|
<circle cx="15" cy="12" r="3.5"/>
|
||||||
|
<path d="M9 26c0-5 2.7-8 6-8s6 3 6 8z"/>
|
||||||
|
<circle cx="26" cy="16" r="2.6"/>
|
||||||
|
<path d="M21.5 26c0-3.8 2-6 4.5-6s4.5 2.2 4.5 6z"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
<span class="brand-text">
|
||||||
|
<span class="brand-name">Elternbeirat</span>
|
||||||
|
<span class="brand-school">IGMH Mannheim</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<div class="card">
|
||||||
|
<h1>Die Seite ist gerade nicht erreichbar</h1>
|
||||||
|
<p>
|
||||||
|
Wir bitten um Entschuldigung. Die Website des Elternbeirats der IGMH ist im
|
||||||
|
Moment nicht verfügbar. Wir arbeiten daran, bitte versuchen Sie es in Kürze
|
||||||
|
noch einmal.
|
||||||
|
</p>
|
||||||
|
<p class="contact">
|
||||||
|
In dringenden Fällen erreichen Sie den Elternbeirat per E-Mail:<br/>
|
||||||
|
<a href="mailto:kontakt@elternbeirat-igmh.info">kontakt@elternbeirat-igmh.info</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
@@ -128,6 +128,31 @@ development and the tests and are **not** used on Unraid — see `entwicklung.md
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Outage: PocketBase unreachable
|
||||||
|
|
||||||
|
If `eb-pocketbase` is down while `eb-blazor` runs, the app does not render
|
||||||
|
half-empty pages (the menu comes from PocketBase too). A gate in `Program.cs`
|
||||||
|
answers every **page** request with the static `wwwroot/maintenance.html`:
|
||||||
|
|
||||||
|
| Request | Answer while PocketBase is down |
|
||||||
|
|---|---|
|
||||||
|
| Any page (`/`, `/board`, `/posts/…`, unknown slugs) | **503**, `Retry-After: 60`, maintenance page |
|
||||||
|
| `/health` | **503** `PocketBase unreachable` (for Uptime Kuma) |
|
||||||
|
| `/events.ics` | 200 with an empty calendar, so subscriptions keep working |
|
||||||
|
| `/events/{id}.ics` | 503 |
|
||||||
|
| Static files (CSS, fonts, PDFs, `/maintenance.html`) | served as usual |
|
||||||
|
|
||||||
|
The gate caches the health check for **10 seconds** (`PocketBaseHealthCache`). So
|
||||||
|
after PocketBase stops, pages can render for up to 10 s more (the components
|
||||||
|
degrade on their own in that window: empty menu, a short note, 503), and after it
|
||||||
|
starts again the maintenance page can stay up for up to 10 s. No restart of the
|
||||||
|
web app is needed — the site comes back by itself.
|
||||||
|
|
||||||
|
`maintenance.html` carries its styles inline, as a copy of the tokens from
|
||||||
|
`app.css`. When colours change there, copy them over.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Rollback
|
## Rollback
|
||||||
|
|
||||||
`scripts/release.sh` additionally tags each release with the short commit SHA,
|
`scripts/release.sh` additionally tags each release with the short commit SHA,
|
||||||
|
|||||||
+97
-2
@@ -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).
|
||||||
@@ -143,6 +178,11 @@ der Startseite als Vorschau angeteasert.
|
|||||||
- `slug` ist englisch, klein, ohne Umlaute (z. B. `new-sports-hall-opened`) —
|
- `slug` ist englisch, klein, ohne Umlaute (z. B. `new-sports-hall-opened`) —
|
||||||
dieselbe erzwungene Feldregel wie bei Seiten (siehe *Seiten → Slug-Regel wird
|
dieselbe erzwungene Feldregel wie bei Seiten (siehe *Seiten → Slug-Regel wird
|
||||||
erzwungen*).
|
erzwungen*).
|
||||||
|
- **Vorschautext auf der Karte:** Die Karte zeigt die ersten rund 160 Zeichen
|
||||||
|
des `body` als reinen Text (Überschriften und Formatierung fallen weg, gekürzt
|
||||||
|
am Wortende mit „…“). Ein eigenes Feld dafür gibt es nicht — beginne den Text
|
||||||
|
deshalb am besten mit einem Satz, der sagt, worum es geht. Auf `/posts`
|
||||||
|
erscheint der neueste Beitrag als große Karte über die volle Breite.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -153,8 +193,23 @@ mehrtägige), `title`, `location` (optional), `note` (optional) und `public`.
|
|||||||
Kein Slug.
|
Kein Slug.
|
||||||
|
|
||||||
Die Übersicht `/events` trennt automatisch in kommende und vergangene Termine;
|
Die Übersicht `/events` trennt automatisch in kommende und vergangene Termine;
|
||||||
die Reihenfolge der Records spielt keine Rolle. Besucher können `/events.ics` in
|
die Reihenfolge der Records spielt keine Rolle. Vergangene Termine stehen
|
||||||
ihrer Kalender-App abonnieren — der Feed entsteht aus denselben Records.
|
zugeklappt unter „Vergangene Termine anzeigen“. Besucher können `/events.ics` in
|
||||||
|
ihrer Kalender-App abonnieren (Knopf „Kalender abonnieren“) — der Feed entsteht
|
||||||
|
aus denselben Records. Jeder kommende Termin hat außerdem einen Link „In Kalender
|
||||||
|
übernehmen“, der nur diesen einen Termin als Kalenderdatei liefert.
|
||||||
|
|
||||||
|
So erscheint ein Termin auf seiner Karte:
|
||||||
|
|
||||||
|
- Links ein **Kalenderblatt** mit Monat, Tag und Wochentag des `start`.
|
||||||
|
- Daneben die **Uhrzeit**: nur der Beginn, mit `end` am selben Tag die Spanne
|
||||||
|
(`19:30–21:00 Uhr`), bei mehrtägigen Terminen „bis …“ mit dem letzten Tag. Ein
|
||||||
|
ganztägiger Termin (Uhrzeit `00:00`) ohne `end` zeigt keine Uhrzeit.
|
||||||
|
- Darunter `location` und `note`. Auf der Startseite wird `note` nach zwei
|
||||||
|
Zeilen abgeschnitten, auf `/events` steht sie ganz.
|
||||||
|
|
||||||
|
Einen Einleitungstext über der Liste schreibst du in den `body` der Seite mit
|
||||||
|
dem Slug `events` (unter `pages`); die Website fügt keinen eigenen hinzu.
|
||||||
|
|
||||||
> **Trage einfach die Ortszeit ein.** Gib die Uhrzeit ein, die auf der Seite
|
> **Trage einfach die Ortszeit ein.** Gib die Uhrzeit ein, die auf der Seite
|
||||||
> stehen soll (Europe/Berlin) — z. B. `08:00`. PocketBase speichert intern in UTC
|
> stehen soll (Europe/Berlin) — z. B. `08:00`. PocketBase speichert intern in UTC
|
||||||
@@ -254,6 +309,46 @@ Die Beitrittserklärung füllen Sie online beim Förderverein aus.
|
|||||||
:::
|
:::
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Team** — eine Personenliste wird zu Karten mit Initialen-Kreis. Jeder
|
||||||
|
Listenpunkt ist eine Person:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## Vorsitz
|
||||||
|
|
||||||
|
::: team gross
|
||||||
|
- **Maik Palm** (Vorsitzender)
|
||||||
|
*Schulkonferenz, Mensarat, Homepage*
|
||||||
|
Mitglied im Gesamtelternbeirat und im Schulbeirat der Stadt Mannheim.
|
||||||
|
- **Gentiana Erol** (1. Stellvertreterin)
|
||||||
|
*Schulkonferenz, Kommunikation Hausmeister, Mensarat*
|
||||||
|
:::
|
||||||
|
|
||||||
|
## Beisitz
|
||||||
|
|
||||||
|
::: team
|
||||||
|
- **Isabell Vesper**
|
||||||
|
- **Susanne Naumburg**
|
||||||
|
*Stellvertretende Schulkonferenz*
|
||||||
|
:::
|
||||||
|
```
|
||||||
|
|
||||||
|
- Der **fette Name** am Anfang wird die Überschrift der Karte, die **Rolle in
|
||||||
|
Klammern** dahinter ein farbiges Etikett. Beides außer dem Namen ist optional.
|
||||||
|
- Die **kursive** zweite Zeile sind die Zuständigkeiten, durch Komma getrennt;
|
||||||
|
jede wird ein kleines Schildchen.
|
||||||
|
- Jede **weitere Zeile** erscheint als kleiner Text darunter (Links gehen).
|
||||||
|
- Zeilenumbruch innerhalb einer Person: zwei Leerzeichen am Zeilenende, die
|
||||||
|
Folgezeilen mit zwei Leerzeichen einrücken (wie oben).
|
||||||
|
- Initialen und Farbe des Kreises entstehen **automatisch aus dem Namen**; es gibt
|
||||||
|
bewusst keine Fotos (siehe `docs/recht.md`).
|
||||||
|
- `::: team gross` ist für den Vorsitz gedacht: Die Karten bleiben gleich groß,
|
||||||
|
nur das Rollen-Etikett wird kräftig blau hervorgehoben. Ein vertipptes Wort
|
||||||
|
nach `team` schadet nicht: Die Etiketten erscheinen dann einfach normal.
|
||||||
|
- Die Überschriften (`## Vorsitz`, `## Beisitz` …) stehen **außerhalb** der
|
||||||
|
Blöcke; jede Gruppe ist ein eigener `::: team`-Block.
|
||||||
|
- Beginnt ein Listenpunkt nicht mit einem fetten Namen, wird er eine schlichte
|
||||||
|
Karte mit genau dem Text, der dasteht.
|
||||||
|
|
||||||
**Hinweis** — eine dezente Info-Box mit Info-Symbol:
|
**Hinweis** — eine dezente Info-Box mit Info-Symbol:
|
||||||
|
|
||||||
```markdown
|
```markdown
|
||||||
|
|||||||
@@ -21,11 +21,11 @@ migrate((app) => {
|
|||||||
const schema = JSON.parse(toString($os.readFile("/pb_migrations/collections_schema.json")))
|
const schema = JSON.parse(toString($os.readFile("/pb_migrations/collections_schema.json")))
|
||||||
|
|
||||||
const records = {
|
const records = {
|
||||||
pages: [{"body":"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::: team gross\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\n## Schriftführung\n\n::: team\n- **Marion Leininger** \n *Kasse, Schriftführung, Kontakt Förderverein*\n:::\n\n## Beisitz\n\n::: team\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\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\nLine 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
|
||||||
,
|
,
|
||||||
events: [{"end":"","location":"","note":"Die neue Internetseite des Elternbeirats der IGMH löst die bisherige ab und ist ab diesem Tag unter der neuen Adresse erreichbar.","public":true,"start":"2026-10-14 22:00:00.000Z","title":"Neue Website des Elternbeirats geht online"},{"end":"","location":"IGMH","note":"","public":true,"start":"2025-02-07 23:00:00.000Z","title":"Tag der offenen Tür der IGMH"},{"end":"","location":"IGMH","note":"","public":true,"start":"2025-02-24 23:00:00.000Z","title":"Wahl des Elternbeiratsvorstands"},{"end":"","location":"Hörsaal 2 der IGMH","note":"","public":true,"start":"2025-11-13 18:00:00.000Z","title":"Vollversammlung des Elternbeirats mit Vorstandswahl"},{"end":"","location":"IGMH","note":"","public":true,"start":"2025-12-08 23:00:00.000Z","title":"1. Sitzung des Elternbeiratsvorstands"},{"end":"","location":"IGMH","note":"","public":true,"start":"2025-12-16 23:00:00.000Z","title":"Adventstüren-Contest"},{"end":"","location":"IGMH","note":"","public":true,"start":"2026-03-09 23:00:00.000Z","title":"2. Sitzung des Elternbeiratsvorstands"}]
|
events: [{"end":"","location":"","note":"Die neue Internetseite des Elternbeirats der IGMH löst die bisherige ab und ist ab diesem Tag unter der neuen Adresse erreichbar.","public":true,"start":"2026-10-14 22:00:00.000Z","title":"Neue Website des Elternbeirats geht online"},{"end":"","location":"IGMH","note":"","public":true,"start":"2025-02-07 23:00:00.000Z","title":"Tag der offenen Tür der IGMH"},{"end":"","location":"IGMH","note":"","public":true,"start":"2025-02-24 23:00:00.000Z","title":"Wahl des Elternbeiratsvorstands"},{"end":"","location":"Hörsaal 2 der IGMH","note":"","public":true,"start":"2025-11-13 18:00:00.000Z","title":"Vollversammlung des Elternbeirats mit Vorstandswahl"},{"end":"","location":"IGMH","note":"","public":true,"start":"2025-12-08 23:00:00.000Z","title":"1. Sitzung des Elternbeiratsvorstands"},{"end":"","location":"IGMH","note":"","public":true,"start":"2025-12-16 23:00:00.000Z","title":"Adventstüren-Contest"},{"end":"","location":"IGMH","note":"","public":true,"start":"2026-03-09 23:00:00.000Z","title":"2. Sitzung des Elternbeiratsvorstands"},{"end":"2026-11-21 12:00:00.000Z","location":"Schulhof der IGMH","note":"Kuchenspenden bitte bis Freitag bei der Klassenleitung anmelden.","public":true,"start":"2026-11-21 08:00:00.000Z","title":"Herbstbasar"},{"end":"2026-12-11 11:00:00.000Z","location":"Jugendherberge Heidelberg","note":"","public":true,"start":"2026-12-09 07:00:00.000Z","title":"Klausurtagung des Elternbeiratsvorstands"},{"end":"2026-07-02 22:00:00.000Z","location":"","note":"","public":true,"start":"2026-06-28 22:00:00.000Z","title":"Projektwoche"}]
|
||||||
,
|
,
|
||||||
// Topics own the grouping and the order of the FAQ page; a question links to
|
// Topics own the grouping and the order of the FAQ page; a question links to
|
||||||
// its topic below via the "topic" relation, resolved by slug at seed time.
|
// its topic below via the "topic" relation, resolved by slug at seed time.
|
||||||
|
|||||||
Reference in new issue
Block a user