Merge pull request 'Render board members as person cards via ::: team block' (#56) from design/board-team into main
Build and push image / build (push) Successful in 2m1s
Build and push image / build (push) Successful in 2m1s
Reviewed-on: #56
This commit was merged in pull request #56.
This commit is contained in:
commit
ae362639e3
8 files changed
+814
-10
No files matched your search
@@ -235,7 +235,16 @@ public sealed class PocketBaseFixture : IAsyncLifetime
|
||||
});
|
||||
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,
|
||||
});
|
||||
await CreateRecordAsync(http, "pages", new
|
||||
|
||||
@@ -198,6 +198,28 @@ public sealed partial class RouteSmokeTests : IDisposable
|
||||
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]
|
||||
public async Task Posts_and_events_render_their_page_heading_and_body()
|
||||
{
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using Markdig;
|
||||
using Markdig.Extensions.CustomContainers;
|
||||
using Markdig.Renderers.Html;
|
||||
using Markdig.Syntax;
|
||||
using Markdig.Syntax.Inlines;
|
||||
@@ -20,8 +21,8 @@ namespace Elternbeirat.Web.Shared;
|
||||
/// <b>Custom containers</b> (<c>::: name</c> … <c>:::</c>) turn a block
|
||||
/// into <c><div class="name"></c>. That is how editors use the
|
||||
/// design blocks (<c>kennzahlen</c>, <c>aufruf</c>, <c>kacheln</c>,
|
||||
/// <c>hinweis</c>) without writing HTML. An unknown name just yields a
|
||||
/// div without styling, so a typo never breaks a page.
|
||||
/// <c>hinweis</c>, <c>team</c>) without writing HTML. An unknown name
|
||||
/// just yields a div without styling, so a typo never breaks a page.
|
||||
/// </description>
|
||||
/// </item>
|
||||
/// <item>
|
||||
@@ -45,8 +46,14 @@ namespace Elternbeirat.Web.Shared;
|
||||
/// 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.
|
||||
/// </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>
|
||||
/// <seealso cref="IconSet"/>
|
||||
/// <seealso cref="TeamMember"/>
|
||||
public static partial class Markdown
|
||||
{
|
||||
/// <summary>
|
||||
@@ -183,22 +190,24 @@ public static partial class Markdown
|
||||
Markdig.Markdown.Parse(markdown, Pipeline);
|
||||
|
||||
/// <summary>
|
||||
/// Renders a document from <see cref="Parse"/> to HTML, marking lone links on
|
||||
/// the way, exactly as <see cref="ToHtml"/> does.
|
||||
/// 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(document), Pipeline);
|
||||
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="inline">The inline content, e.g. of a paragraph or heading.</param>
|
||||
/// <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(ContainerInline inline) =>
|
||||
Whitespace().Replace(AppendText(new StringBuilder(), inline).ToString(), " ").Trim();
|
||||
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).
|
||||
@@ -279,6 +288,35 @@ public static partial class Markdown
|
||||
[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>
|
||||
/// Adds <see cref="LoneLinkClass"/> to every paragraph that holds nothing but
|
||||
/// one link.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -74,6 +74,17 @@
|
||||
--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. */
|
||||
@@ -181,6 +192,10 @@
|
||||
--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
|
||||
@@ -853,6 +868,130 @@ h1:focus {
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
/* ::: 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.
|
||||
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);
|
||||
max-width: none;
|
||||
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
|
||||
with overflow they scroll sideways on a phone instead of stretching the page. */
|
||||
.markdown-body table {
|
||||
|
||||
@@ -309,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:
|
||||
|
||||
```markdown
|
||||
|
||||
@@ -21,7 +21,7 @@ migrate((app) => {
|
||||
const schema = JSON.parse(toString($os.readFile("/pb_migrations/collections_schema.json")))
|
||||
|
||||
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
|
||||
,
|
||||
|
||||
Reference in new issue
Block a user