Compare commits

...
2 Commits
33 changed files with 1150 additions and 206 deletions

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("");
}
+59
View File
@@ -125,4 +125,63 @@ public class MarkdownTests
[InlineData("::: hinweis\nText im Baustein.\n:::")] [InlineData("::: hinweis\nText im Baustein.\n:::")]
public void First_sentence_is_empty_without_a_top_level_paragraph(string? markdown) => public void First_sentence_is_empty_without_a_top_level_paragraph(string? markdown) =>
Markdown.FirstSentence(markdown).ShouldBe(""); 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));
} }
@@ -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]
@@ -338,6 +338,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
+129
View File
@@ -92,6 +92,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();
@@ -328,6 +329,108 @@ public sealed partial class RouteSmokeTests : IDisposable
html.ShouldNotContain("Nächster Termin"); 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");
}
/// <summary> /// <summary>
/// Matches a resource reference that points at an absolute URL, with or without /// Matches a resource reference that points at an absolute URL, with or without
/// scheme (<c>https://</c>, <c>http://</c> or protocol-relative <c>//</c>): the /// scheme (<c>https://</c>, <c>http://</c> or protocol-relative <c>//</c>): the
@@ -356,4 +459,30 @@ public sealed partial class RouteSmokeTests : IDisposable
/// </summary> /// </summary>
[GeneratedRegex("""<section class="home-tiles".*?</section>""", RegexOptions.Singleline)] [GeneratedRegex("""<section class="home-tiles".*?</section>""", RegexOptions.Singleline)]
private static partial Regex HomeTiles(); 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>&lt;/a&gt;</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>&lt;ul&gt;</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();
} }
@@ -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,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,87 @@
/* 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-card-link:focus-visible {
outline: 3px solid var(--color-focus);
outline-offset: 2px;
}
.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)
{ {
<li> <h3 class="past-events-year">@year.Key</h3>
<a class="card-link" href="/events"> <ul>
<span class="event-title">@ev.Title</span> @foreach (var ev in year)
<EventDate Value="ev" /> {
@if (!string.IsNullOrWhiteSpace(ev.Location)) var time = EventDateFormat.PastTime(ev);
{ <li>
<span class="event-location">@ev.Location</span> <span class="past-event-date">@EventDateFormat.PastDays(ev)</span>
} <span class="past-event-title">
</a> @ev.Title
</li> @if (time.Length > 0)
{
<span class="past-event-time">@time</span>
}
</span>
</li>
}
</ul>
} }
</ul> </details>
} }
} }
@@ -39,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);
@@ -1,2 +1,70 @@
/* 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. */
.events-subscribe {
margin: var(--space-4) 0;
}
/* 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 {
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;
}
+1 -1
View File
@@ -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))
{ {
+11 -8
View File
@@ -29,21 +29,24 @@
@if (_nextEvent is not null) @if (_nextEvent is not null)
{ {
@* The calendar sheet repeats the date as decoration, so it is hidden from @* As in EventCard: the calendar sheet shows the date and is hidden from
screen readers; <EventDate> carries the full date for everyone. *@ 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"> <a class="next-event" href="/events">
<span class="next-event-label">Nächster Termin</span> <span class="next-event-label">Nächster Termin</span>
<span class="next-event-row"> <span class="next-event-row">
<span class="calendar-sheet" aria-hidden="true"> <CalendarSheet Value="_nextEvent.Start" />
<span class="calendar-month">@NextEventMonth</span>
<span class="calendar-day">@NextEventDay</span>
</span>
<span class="next-event-details"> <span class="next-event-details">
<span class="next-event-title">@_nextEvent.Title</span> <span class="next-event-title">@_nextEvent.Title</span>
<EventDate Value="_nextEvent" /> <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)) @if (!string.IsNullOrWhiteSpace(_nextEvent.Location))
{ {
<span class="next-event-location"><Icon Name="map-pin" Size="16" /> @_nextEvent.Location</span> <span class="next-event-meta"><Icon Name="map-pin" Size="16" /> @_nextEvent.Location</span>
} }
</span> </span>
</span> </span>
@@ -46,14 +46,6 @@ public partial class Home
private IReadOnlyList<string> _embed = []; private IReadOnlyList<string> _embed = [];
private Event? _nextEvent; private Event? _nextEvent;
// Month and day for the calendar sheet. The sheet is decoration (the full date is
// in <EventDate> next to it), so the short month drops its trailing period.
private string NextEventMonth =>
_nextEvent?.Start.ToString("MMM", Cultures.German).TrimEnd('.') ?? "";
private string NextEventDay =>
_nextEvent?.Start.Day.ToString(Cultures.German) ?? "";
protected override Task OnInitializedAsync() protected override Task OnInitializedAsync()
{ {
var token = HttpContext?.RequestAborted ?? CancellationToken.None; var token = HttpContext?.RequestAborted ?? CancellationToken.None;
+7 -33
View File
@@ -136,38 +136,6 @@
align-items: flex-start; align-items: flex-start;
} }
/* The calendar sheet: a blue band with the month over a large day number. The band
uses the button blue, which keeps white text at >= 5.5:1 in both schemes (the
green accent would fall below AA for text this small). */
.calendar-sheet {
flex-shrink: 0;
display: flex;
flex-direction: column;
width: 4rem;
overflow: hidden;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-bg);
text-align: center;
}
.calendar-month {
padding: 0.1rem 0;
background: var(--color-button);
color: var(--color-on-button);
font-size: 0.8rem;
font-weight: 700;
text-transform: uppercase;
}
.calendar-day {
padding: var(--space-1) 0;
color: var(--color-heading);
font-size: 1.75rem;
font-weight: var(--font-weight-heading);
line-height: 1.2;
}
.next-event-details { .next-event-details {
min-width: 0; /* lets a long title wrap instead of widening the card */ min-width: 0; /* lets a long title wrap instead of widening the card */
} }
@@ -180,7 +148,9 @@
line-height: 1.3; line-height: 1.3;
} }
.next-event-location { /* 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; display: flex;
align-items: center; align-items: center;
gap: var(--space-1); gap: var(--space-1);
@@ -189,6 +159,10 @@
font-size: 0.9rem; font-size: 0.9rem;
} }
.next-event-meta ::deep .icon {
color: var(--color-accent-dark);
}
/* --- Tiles -------------------------------------------------------------------- /* --- Tiles --------------------------------------------------------------------
One card per header page. Fixed columns instead of auto-fill: two on a phone, 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 three from tablet width, so the six pages always fill whole rows and never leave
@@ -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,93 @@
/* 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);
}
.post-card:focus-visible {
outline: 3px solid var(--color-focus);
outline-offset: 2px;
}
/* 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;
}
@@ -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,12 @@
/* 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;
}
}
+22
View File
@@ -69,6 +69,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
+73
View File
@@ -55,6 +55,12 @@ public static partial 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()
@@ -120,6 +126,53 @@ public static partial class Markdown
? UpToSentenceEnd(PlainText(inline)).TrimEnd(':', ' ') ? 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> /// <summary>
/// Parses Markdown with the site's pipeline, for callers that take the document /// Parses Markdown with the site's pipeline, for callers that take the document
/// apart before rendering it (see <see cref="Render"/>). /// apart before rendering it (see <see cref="Render"/>).
@@ -192,6 +245,26 @@ public static partial class Markdown
private static string UpToSentenceEnd(string text) => private static string UpToSentenceEnd(string text) =>
SentenceEnd().Match(text) is { Success: true } end ? text[..(end.Index + 1)] : 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> /// <summary>
/// Matches the end mark of a sentence: <c>.</c>, <c>!</c> or <c>?</c> before a /// 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 /// space or the end, unless it follows a lone letter (<c>z.</c>) or a number
+35 -84
View File
@@ -54,6 +54,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
@@ -165,6 +170,7 @@
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 */
@@ -289,6 +295,22 @@ h1:focus {
outline: none; outline: none;
} }
/* 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. */
@@ -400,12 +422,12 @@ h1:focus {
} }
} }
/* --- 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 {
@@ -416,29 +438,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
@@ -456,7 +455,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;
@@ -467,17 +469,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: "▾";
} }
@@ -491,57 +493,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);
+22 -2
View File
@@ -178,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.
--- ---
@@ -188,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
+1 -1
View File
@@ -25,7 +25,7 @@ migrate((app) => {
, ,
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.