Add home hero with intro and buttons from the body, next-event card and page tiles

This commit is contained in:
tleininger committed 2026-10-01 14:05:25 +02:00
1 parent b737f57ef7
commit e51afeb34b
23 files changed
+1262 -65

No files matched your search

+128
View File
@@ -0,0 +1,128 @@
using Elternbeirat.Web.Shared;
using Markdig.Syntax;
using Markdig.Syntax.Inlines;
namespace Elternbeirat.Web.Features.Home;
/// <summary>
/// The head of the home page, taken apart from the <c>home</c> page's Markdown body:
/// a large heading, an intro and a row of buttons.
/// </summary>
/// <param name="Title">The large heading.</param>
/// <param name="IntroHtml">The intro rendered to HTML, or an empty string.</param>
/// <param name="Links">The buttons, in the order the editor wrote them; may be empty.</param>
/// <remarks>
/// Everything visible here is written by editors in PocketBase. The page only gives
/// it the hero layout, so the editor needs no new field and no special syntax:
/// <list type="bullet">
/// <item><description>The first <c># heading</c> becomes the large heading.</description></item>
/// <item>
/// <description>
/// A last paragraph made of nothing but links becomes the button row;
/// the first link is the primary button.
/// </description>
/// </item>
/// <item><description>Everything else is the intro text.</description></item>
/// </list>
/// <para>
/// <see cref="Fallback"/> keeps a static heading when PocketBase cannot be
/// reached, so the start page never renders without a title.
/// </para>
/// </remarks>
/// <example>
/// <code>
/// HomeIntro.Parse("# Willkommen\n\nSchön, dass Sie da sind.\n\n[Termine](/events) [Kontakt](/contact)");
/// // Title "Willkommen", IntroHtml "&lt;p&gt;Schön, dass Sie da sind.&lt;/p&gt;\n", two links
/// </code>
/// </example>
public sealed record HomeIntro(string Title, string IntroHtml, IReadOnlyList<HomeLink> Links)
{
/// <summary>
/// The heading shown when the body has none or PocketBase is not reachable.
/// </summary>
public const string FallbackTitle = "Elternbeirat der IGMH";
/// <summary>
/// The head of the home page without any content from PocketBase: the static
/// heading, no intro, no buttons.
/// </summary>
public static HomeIntro Fallback { get; } = new(FallbackTitle, "", []);
/// <summary>
/// Takes the <c>home</c> page's body apart into heading, intro and buttons.
/// </summary>
/// <param name="markdown">The body; may be <see langword="null"/> or empty.</param>
/// <returns>
/// The parts; <see cref="Fallback"/> for an empty body. Without a level-1
/// heading, the title is <see cref="FallbackTitle"/> and the rest is used as is.
/// </returns>
/// <remarks>
/// Only top-level blocks are looked at: a heading or a link paragraph inside a
/// list or a <c>:::</c> block stays part of the intro. Links in the button row
/// may be separated by spaces or line breaks; any other text in the paragraph
/// (e.g. "oder") keeps it a normal paragraph, because the button row would drop
/// that text.
/// </remarks>
public static HomeIntro Parse(string? markdown)
{
if (string.IsNullOrWhiteSpace(markdown))
{
return Fallback;
}
var document = Markdown.Parse(markdown);
var heading = document.OfType<HeadingBlock>().FirstOrDefault(block => block.Level == 1);
if (heading is not null)
{
document.Remove(heading);
}
var links = document.LastChild is ParagraphBlock last ? LinksOnly(last) : [];
if (links.Count > 0)
{
document.RemoveAt(document.Count - 1);
}
var title = heading?.Inline is { } inline ? Markdown.PlainText(inline) : "";
return new HomeIntro(
string.IsNullOrEmpty(title) ? FallbackTitle : title,
Markdown.Render(document),
links);
}
/// <summary>
/// Reads the links of a paragraph that holds nothing but links.
/// </summary>
/// <param name="paragraph">The paragraph to inspect.</param>
/// <returns>
/// Its links, or an empty list if the paragraph contains anything besides links
/// and whitespace (including an image).
/// </returns>
private static List<HomeLink> LinksOnly(ParagraphBlock paragraph) =>
paragraph.Inline?.Where(inline => !Markdown.IsBlank(inline)).ToList() is { Count: > 0 } inlines
&& inlines.All(inline => inline is LinkInline { IsImage: false } or AutolinkInline)
? [.. inlines.Select(ToLink)]
: [];
/// <summary>
/// Turns a link inline into a button.
/// </summary>
/// <param name="inline">A <see cref="LinkInline"/> or <see cref="AutolinkInline"/>.</param>
/// <returns>The button's target and visible text.</returns>
/// <exception cref="ArgumentException"><paramref name="inline"/> is neither kind of link.</exception>
private static HomeLink ToLink(Inline inline) =>
inline switch
{
LinkInline link => new HomeLink(link.Url ?? "", Markdown.PlainText(link)),
AutolinkInline autolink => new HomeLink(autolink.Url, autolink.Url),
_ => throw new ArgumentException("Not a link.", nameof(inline)),
};
}
/// <summary>
/// A button in the head of the home page.
/// </summary>
/// <param name="Href">The link target as the editor wrote it.</param>
/// <param name="Text">The visible button text.</param>
public sealed record HomeLink(string Href, string Text);