Add home hero with intro and buttons from the body, next-event card and page tiles
This commit is contained in:
1 parent
b737f57ef7
commit
e51afeb34b
23 files changed
+1262
-65
No files matched your search
@@ -1,12 +1,78 @@
|
||||
@page "/"
|
||||
@using Elternbeirat.Web.Features.Events
|
||||
|
||||
<PageTitle>Elternbeirat der IGMH</PageTitle>
|
||||
|
||||
<h1>Elternbeirat der IGMH</h1>
|
||||
@* The hero spans the full window width (see Home.razor.css). Its text is the "home"
|
||||
page's body, taken apart by HomeIntro; the card beside it shows the next event. *@
|
||||
<section class="hero">
|
||||
<div class="hero-inner">
|
||||
<div class="hero-text">
|
||||
<h1>@_intro.Title</h1>
|
||||
|
||||
<p class="intro">
|
||||
Willkommen beim Elternbeirat der IGMH. Hier finden Sie aktuelle Informationen,
|
||||
Antworten auf häufige Fragen und Unterlagen rund um die Elternarbeit.
|
||||
</p>
|
||||
@if (_intro.IntroHtml.Length > 0)
|
||||
{
|
||||
<div class="hero-intro">@((MarkupString)_intro.IntroHtml)</div>
|
||||
}
|
||||
|
||||
<PageEmbeds Embed="_embed" />
|
||||
@if (_intro.Links.Count > 0)
|
||||
{
|
||||
<p class="hero-actions">
|
||||
@for (var i = 0; i < _intro.Links.Count; i++)
|
||||
{
|
||||
var link = _intro.Links[i];
|
||||
<a class="btn @(i == 0 ? "btn-primary" : "btn-secondary")" href="@link.Href">@link.Text</a>
|
||||
}
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (_nextEvent is not null)
|
||||
{
|
||||
@* The calendar sheet repeats the date as decoration, so it is hidden from
|
||||
screen readers; <EventDate> carries the full date for everyone. *@
|
||||
<a class="next-event" href="/events">
|
||||
<span class="next-event-label">Nächster Termin</span>
|
||||
<span class="next-event-row">
|
||||
<span class="calendar-sheet" aria-hidden="true">
|
||||
<span class="calendar-month">@NextEventMonth</span>
|
||||
<span class="calendar-day">@NextEventDay</span>
|
||||
</span>
|
||||
<span class="next-event-details">
|
||||
<span class="next-event-title">@_nextEvent.Title</span>
|
||||
<EventDate Value="_nextEvent" />
|
||||
@if (!string.IsNullOrWhiteSpace(_nextEvent.Location))
|
||||
{
|
||||
<span class="next-event-location"><Icon Name="map-pin" Size="16" /> @_nextEvent.Location</span>
|
||||
}
|
||||
</span>
|
||||
</span>
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@if (_tiles.Count > 0)
|
||||
{
|
||||
<nav class="home-tiles" aria-label="Bereiche der Website">
|
||||
<ul>
|
||||
@foreach (var tile in _tiles)
|
||||
{
|
||||
<li>
|
||||
<a class="tile-link" href="@tile.Href">
|
||||
<span class="tile-icon"><Icon Name="@tile.Icon" Size="24" /></span>
|
||||
<span class="tile-title">@tile.Title</span>
|
||||
@if (tile.Description.Length > 0)
|
||||
{
|
||||
<span class="tile-text">@tile.Description</span>
|
||||
}
|
||||
</a>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</nav>
|
||||
}
|
||||
|
||||
<div class="home-embeds">
|
||||
<PageEmbeds Embed="_embed" />
|
||||
</div>
|
||||
@@ -1,5 +1,7 @@
|
||||
using Elternbeirat.Contracts;
|
||||
using Elternbeirat.PocketBase;
|
||||
using Elternbeirat.Web.Features.Events;
|
||||
using Elternbeirat.Web.Shared;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
|
||||
namespace Elternbeirat.Web.Features.Home;
|
||||
@@ -8,8 +10,15 @@ public partial class Home
|
||||
{
|
||||
[LoggerMessage(
|
||||
Level = LogLevel.Warning,
|
||||
Message = "Could not load the home page from PocketBase; embeds are omitted.")]
|
||||
private static partial void LogSourceUnavailable(
|
||||
Message = "Could not load the pages for the home page from PocketBase; showing the static title only.")]
|
||||
private static partial void LogPagesUnavailable(
|
||||
ILogger logger,
|
||||
Exception exception);
|
||||
|
||||
[LoggerMessage(
|
||||
Level = LogLevel.Warning,
|
||||
Message = "Could not load events for the home page from PocketBase; the next-event card is omitted.")]
|
||||
private static partial void LogEventsUnavailable(
|
||||
ILogger logger,
|
||||
Exception exception);
|
||||
|
||||
@@ -19,26 +28,67 @@ public partial class Home
|
||||
[Inject]
|
||||
private ILogger<Home> Logger { get; set; } = null!;
|
||||
|
||||
[Inject]
|
||||
private TimeProvider Time { get; set; } = null!;
|
||||
|
||||
[CascadingParameter]
|
||||
private HttpContext? HttpContext { get; set; }
|
||||
|
||||
// Which dynamic blocks the home page shows is editorial: it comes from the
|
||||
// "home" page's embed field in PocketBase, not from hard-coded markup.
|
||||
private IReadOnlyList<string> _embed = [];
|
||||
// Heading, intro and buttons come from the "home" page's body. Until it is loaded
|
||||
// -- and for good if PocketBase is down -- the static fallback heading stands, so
|
||||
// the start page always has a title.
|
||||
private HomeIntro _intro = HomeIntro.Fallback;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
// The hero, the tiles and the next-event card are fixed structure of the start
|
||||
// page; which dynamic blocks follow below is editorial and comes from the "home"
|
||||
// page's embed field.
|
||||
private IReadOnlyList<HomeTile> _tiles = [];
|
||||
private IReadOnlyList<string> _embed = [];
|
||||
private Event? _nextEvent;
|
||||
|
||||
// Month and day for the calendar sheet. The sheet is decoration (the full date is
|
||||
// in <EventDate> next to it), so the short month drops its trailing period.
|
||||
private string NextEventMonth =>
|
||||
_nextEvent?.Start.ToString("MMM", Cultures.German).TrimEnd('.') ?? "";
|
||||
|
||||
private string NextEventDay =>
|
||||
_nextEvent?.Start.Day.ToString(Cultures.German) ?? "";
|
||||
|
||||
protected override Task OnInitializedAsync()
|
||||
{
|
||||
var token = HttpContext?.RequestAborted ?? CancellationToken.None;
|
||||
return Task.WhenAll(LoadPagesAsync(token), LoadNextEventAsync(token));
|
||||
}
|
||||
|
||||
// Pages and events load side by side and fail on their own: without events the
|
||||
// page loses only the card, without pages only text and tiles. Either way the
|
||||
// start page stays a 200 rather than a 503 -- it is degraded, not broken.
|
||||
private async Task LoadPagesAsync(CancellationToken token)
|
||||
{
|
||||
try
|
||||
{
|
||||
var pages = await PocketBase.GetPagesAsync(HttpContext?.RequestAborted ?? default);
|
||||
var pages = await PocketBase.GetPagesAsync(token);
|
||||
var home = pages.FirstOrDefault(page => Page.IsHomeSlug(page.Slug));
|
||||
_intro = HomeIntro.Parse(home?.Body);
|
||||
_embed = home?.Embed ?? [];
|
||||
_tiles = HomeTile.From(pages);
|
||||
}
|
||||
catch (PocketBaseUnavailableException exception)
|
||||
{
|
||||
// Only the embeds fail: render the static intro without them instead of a
|
||||
// 500. The home page's own text is markup, so it stays intact.
|
||||
LogSourceUnavailable(Logger, exception);
|
||||
LogPagesUnavailable(Logger, exception);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task LoadNextEventAsync(CancellationToken token)
|
||||
{
|
||||
try
|
||||
{
|
||||
var events = await PocketBase.GetEventsAsync(token);
|
||||
_nextEvent = UpcomingEvents.From(events, BerlinClock.Today(Time)).FirstOrDefault();
|
||||
}
|
||||
catch (PocketBaseUnavailableException exception)
|
||||
{
|
||||
LogEventsUnavailable(Logger, exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,266 @@
|
||||
.intro {
|
||||
/* --- Hero ---------------------------------------------------------------------
|
||||
A full-width band in the brand gradient with a faint dot pattern on top. The
|
||||
page content sits in a centred column (.content in MainLayout), so the band
|
||||
breaks out of it: 100vw wide, pulled left by the column's offset. body clips the
|
||||
few pixels of scrollbar this adds (see app.css). The negative top margin cancels
|
||||
the column's top padding, so the band meets the header without a strip of page
|
||||
background in between.
|
||||
The pattern is an inline SVG in a data: URL -- no request, nothing from another
|
||||
host. White dots at 12% opacity: visible as texture, too faint to cost the
|
||||
white text any contrast. */
|
||||
.hero {
|
||||
width: 100vw;
|
||||
margin: calc(-1 * var(--space-4)) 0 var(--space-5) calc(50% - 50vw);
|
||||
background:
|
||||
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24'%3E%3Ccircle cx='3' cy='3' r='1.5' fill='%23fff' fill-opacity='.12'/%3E%3C/svg%3E"),
|
||||
var(--gradient-brand);
|
||||
color: var(--color-on-header);
|
||||
}
|
||||
|
||||
/* The inside follows the page frame again, so the hero text lines up with the
|
||||
tiles and lists below. Stacked on a phone; from tablet width the next-event card
|
||||
moves beside the text. */
|
||||
.hero-inner {
|
||||
box-sizing: border-box;
|
||||
max-width: var(--content-width);
|
||||
margin: 0 auto;
|
||||
padding: var(--space-6) 1.25rem;
|
||||
display: grid;
|
||||
gap: var(--space-5);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
@media (min-width: 48rem) {
|
||||
.hero-inner {
|
||||
grid-template-columns: minmax(0, 1fr) minmax(16rem, 22rem);
|
||||
}
|
||||
}
|
||||
|
||||
/* On the gradient everything is white: the global heading colour is a blue that
|
||||
would vanish here. */
|
||||
.hero h1 {
|
||||
color: var(--color-on-header);
|
||||
}
|
||||
|
||||
/* The intro is editor Markdown, rendered without scope attributes, so it is reached
|
||||
through ::deep. Slightly larger than body text, as a lead. Links stay white and
|
||||
underlined: the blue link colour would be unreadable on the gradient. */
|
||||
.hero-intro ::deep p {
|
||||
font-size: 1.15rem;
|
||||
}
|
||||
|
||||
.hero-intro ::deep a,
|
||||
.hero-intro ::deep a:hover {
|
||||
color: var(--color-on-header);
|
||||
}
|
||||
|
||||
.hero-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-3);
|
||||
margin: var(--space-4) 0 0;
|
||||
}
|
||||
|
||||
/* The hero's buttons, adapted to the dark gradient: the primary turns light (a
|
||||
filled blue would melt into the blue end), the secondary becomes a white outline
|
||||
with the same translucent wash as the header menu on hover. The focus ring turns
|
||||
white for the same reason. */
|
||||
.hero .btn-primary {
|
||||
background: var(--color-button-light);
|
||||
border-color: var(--color-button-light);
|
||||
color: var(--color-on-button-light);
|
||||
}
|
||||
|
||||
.hero .btn-primary:hover {
|
||||
background: var(--color-button-light-hover);
|
||||
border-color: var(--color-button-light-hover);
|
||||
color: var(--color-on-button-light);
|
||||
}
|
||||
|
||||
.hero .btn-secondary {
|
||||
border-color: var(--color-on-header);
|
||||
color: var(--color-on-header);
|
||||
}
|
||||
|
||||
.hero .btn-secondary:hover {
|
||||
background: var(--color-nav-hover);
|
||||
color: var(--color-on-header);
|
||||
}
|
||||
|
||||
.hero .btn:focus-visible {
|
||||
outline-color: var(--color-on-header);
|
||||
}
|
||||
|
||||
/* --- Next event ---------------------------------------------------------------
|
||||
A card on the gradient: the regular card surface, so its text uses the normal
|
||||
tokens and adapts to dark mode by itself. The whole card is one link to the
|
||||
events page. */
|
||||
.next-event {
|
||||
display: block;
|
||||
padding: var(--space-4);
|
||||
border-radius: var(--radius);
|
||||
background: var(--color-bg);
|
||||
box-shadow: var(--shadow-lg);
|
||||
color: var(--color-text);
|
||||
text-decoration: none;
|
||||
transition: box-shadow var(--transition);
|
||||
}
|
||||
|
||||
.next-event:hover {
|
||||
color: var(--color-text);
|
||||
box-shadow: var(--shadow-hover), var(--shadow-lg);
|
||||
}
|
||||
|
||||
/* The ring sits outside the card, on the gradient, so it is white like the
|
||||
hero's button rings. */
|
||||
.next-event:focus-visible {
|
||||
outline: 3px solid var(--color-on-header);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
.next-event-label {
|
||||
display: block;
|
||||
margin-bottom: var(--space-3);
|
||||
color: var(--color-text-soft);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.next-event-row {
|
||||
display: flex;
|
||||
gap: var(--space-3);
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
/* The calendar sheet: a blue band with the month over a large day number. The band
|
||||
uses the button blue, which keeps white text at >= 5.5:1 in both schemes (the
|
||||
green accent would fall below AA for text this small). */
|
||||
.calendar-sheet {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 4rem;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-bg);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.calendar-month {
|
||||
padding: 0.1rem 0;
|
||||
background: var(--color-button);
|
||||
color: var(--color-on-button);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.calendar-day {
|
||||
padding: var(--space-1) 0;
|
||||
color: var(--color-heading);
|
||||
font-size: 1.75rem;
|
||||
font-weight: var(--font-weight-heading);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.next-event-details {
|
||||
min-width: 0; /* lets a long title wrap instead of widening the card */
|
||||
}
|
||||
|
||||
.next-event-title {
|
||||
display: block;
|
||||
color: var(--color-brand);
|
||||
font-size: 1.15rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.next-event-location {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
margin-top: var(--space-1);
|
||||
color: var(--color-text-soft);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* --- Tiles --------------------------------------------------------------------
|
||||
One card per header page. auto-fill with a minimum width gives as many columns
|
||||
as fit (one on a phone) without breakpoints. Hover = stronger shadow and a blue
|
||||
edge, nothing moves. */
|
||||
.home-tiles ul {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr));
|
||||
gap: var(--space-3);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.tile-link {
|
||||
box-sizing: border-box;
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
column-gap: var(--space-3);
|
||||
align-items: center;
|
||||
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);
|
||||
text-decoration: none;
|
||||
transition: box-shadow var(--transition), border-color var(--transition);
|
||||
}
|
||||
|
||||
.tile-link:hover {
|
||||
border-color: var(--color-brand);
|
||||
box-shadow: var(--shadow-hover);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.tile-link:focus-visible {
|
||||
outline: 3px solid var(--color-focus);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* The icon in a soft round tint; it spans both rows, so title and text line up
|
||||
beside it whether there is a text or not. */
|
||||
.tile-icon {
|
||||
grid-row: span 2;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
border-radius: 50%;
|
||||
background: var(--color-brand-tint);
|
||||
color: var(--color-brand);
|
||||
}
|
||||
|
||||
.tile-title {
|
||||
color: var(--color-brand);
|
||||
font-size: 1.1rem;
|
||||
/* Softer than body text, via the token -- so it adapts in dark mode instead of
|
||||
staying a fixed dark grey that turns unreadable there. */
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.tile-text {
|
||||
color: var(--color-text-soft);
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
/* --- Embeds -------------------------------------------------------------------
|
||||
The blocks the home page opts into (posts, events) sit side by side from desktop
|
||||
width, so the start page shows both without a long scroll. One column below. */
|
||||
@media (min-width: 64rem) {
|
||||
.home-embeds {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--space-5);
|
||||
align-items: start;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
using Elternbeirat.Web.Shared;
|
||||
using Markdig.Syntax;
|
||||
using Markdig.Syntax.Inlines;
|
||||
|
||||
namespace Elternbeirat.Web.Features.Home;
|
||||
|
||||
/// <summary>
|
||||
/// The head of the home page, taken apart from the <c>home</c> page's Markdown body:
|
||||
/// a large heading, an intro and a row of buttons.
|
||||
/// </summary>
|
||||
/// <param name="Title">The large heading.</param>
|
||||
/// <param name="IntroHtml">The intro rendered to HTML, or an empty string.</param>
|
||||
/// <param name="Links">The buttons, in the order the editor wrote them; may be empty.</param>
|
||||
/// <remarks>
|
||||
/// Everything visible here is written by editors in PocketBase. The page only gives
|
||||
/// it the hero layout, so the editor needs no new field and no special syntax:
|
||||
/// <list type="bullet">
|
||||
/// <item><description>The first <c># heading</c> becomes the large heading.</description></item>
|
||||
/// <item>
|
||||
/// <description>
|
||||
/// A last paragraph made of nothing but links becomes the button row;
|
||||
/// the first link is the primary button.
|
||||
/// </description>
|
||||
/// </item>
|
||||
/// <item><description>Everything else is the intro text.</description></item>
|
||||
/// </list>
|
||||
/// <para>
|
||||
/// <see cref="Fallback"/> keeps a static heading when PocketBase cannot be
|
||||
/// reached, so the start page never renders without a title.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <example>
|
||||
/// <code>
|
||||
/// HomeIntro.Parse("# Willkommen\n\nSchön, dass Sie da sind.\n\n[Termine](/events) [Kontakt](/contact)");
|
||||
/// // Title "Willkommen", IntroHtml "<p>Schön, dass Sie da sind.</p>\n", two links
|
||||
/// </code>
|
||||
/// </example>
|
||||
public sealed record HomeIntro(string Title, string IntroHtml, IReadOnlyList<HomeLink> Links)
|
||||
{
|
||||
/// <summary>
|
||||
/// The heading shown when the body has none or PocketBase is not reachable.
|
||||
/// </summary>
|
||||
public const string FallbackTitle = "Elternbeirat der IGMH";
|
||||
|
||||
/// <summary>
|
||||
/// The head of the home page without any content from PocketBase: the static
|
||||
/// heading, no intro, no buttons.
|
||||
/// </summary>
|
||||
public static HomeIntro Fallback { get; } = new(FallbackTitle, "", []);
|
||||
|
||||
/// <summary>
|
||||
/// Takes the <c>home</c> page's body apart into heading, intro and buttons.
|
||||
/// </summary>
|
||||
/// <param name="markdown">The body; may be <see langword="null"/> or empty.</param>
|
||||
/// <returns>
|
||||
/// The parts; <see cref="Fallback"/> for an empty body. Without a level-1
|
||||
/// heading, the title is <see cref="FallbackTitle"/> and the rest is used as is.
|
||||
/// </returns>
|
||||
/// <remarks>
|
||||
/// Only top-level blocks are looked at: a heading or a link paragraph inside a
|
||||
/// list or a <c>:::</c> block stays part of the intro. Links in the button row
|
||||
/// may be separated by spaces or line breaks; any other text in the paragraph
|
||||
/// (e.g. "oder") keeps it a normal paragraph, because the button row would drop
|
||||
/// that text.
|
||||
/// </remarks>
|
||||
public static HomeIntro Parse(string? markdown)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(markdown))
|
||||
{
|
||||
return Fallback;
|
||||
}
|
||||
|
||||
var document = Markdown.Parse(markdown);
|
||||
|
||||
var heading = document.OfType<HeadingBlock>().FirstOrDefault(block => block.Level == 1);
|
||||
if (heading is not null)
|
||||
{
|
||||
document.Remove(heading);
|
||||
}
|
||||
|
||||
var links = document.LastChild is ParagraphBlock last ? LinksOnly(last) : [];
|
||||
if (links.Count > 0)
|
||||
{
|
||||
document.RemoveAt(document.Count - 1);
|
||||
}
|
||||
|
||||
var title = heading?.Inline is { } inline ? Markdown.PlainText(inline) : "";
|
||||
return new HomeIntro(
|
||||
string.IsNullOrEmpty(title) ? FallbackTitle : title,
|
||||
Markdown.Render(document),
|
||||
links);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads the links of a paragraph that holds nothing but links.
|
||||
/// </summary>
|
||||
/// <param name="paragraph">The paragraph to inspect.</param>
|
||||
/// <returns>
|
||||
/// Its links, or an empty list if the paragraph contains anything besides links
|
||||
/// and whitespace (including an image).
|
||||
/// </returns>
|
||||
private static List<HomeLink> LinksOnly(ParagraphBlock paragraph) =>
|
||||
paragraph.Inline?.Where(inline => !Markdown.IsBlank(inline)).ToList() is { Count: > 0 } inlines
|
||||
&& inlines.All(inline => inline is LinkInline { IsImage: false } or AutolinkInline)
|
||||
? [.. inlines.Select(ToLink)]
|
||||
: [];
|
||||
|
||||
/// <summary>
|
||||
/// Turns a link inline into a button.
|
||||
/// </summary>
|
||||
/// <param name="inline">A <see cref="LinkInline"/> or <see cref="AutolinkInline"/>.</param>
|
||||
/// <returns>The button's target and visible text.</returns>
|
||||
/// <exception cref="ArgumentException"><paramref name="inline"/> is neither kind of link.</exception>
|
||||
private static HomeLink ToLink(Inline inline) =>
|
||||
inline switch
|
||||
{
|
||||
LinkInline link => new HomeLink(link.Url ?? "", Markdown.PlainText(link)),
|
||||
AutolinkInline autolink => new HomeLink(autolink.Url, autolink.Url),
|
||||
_ => throw new ArgumentException("Not a link.", nameof(inline)),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A button in the head of the home page.
|
||||
/// </summary>
|
||||
/// <param name="Href">The link target as the editor wrote it.</param>
|
||||
/// <param name="Text">The visible button text.</param>
|
||||
public sealed record HomeLink(string Href, string Text);
|
||||
@@ -0,0 +1,39 @@
|
||||
using Elternbeirat.Contracts;
|
||||
using Elternbeirat.Web.Features.Layout;
|
||||
using Elternbeirat.Web.Shared;
|
||||
|
||||
namespace Elternbeirat.Web.Features.Home;
|
||||
|
||||
/// <summary>
|
||||
/// One tile on the home page, leading to a section of the site.
|
||||
/// </summary>
|
||||
/// <param name="Href">Target URL of the page.</param>
|
||||
/// <param name="Title">The page's title.</param>
|
||||
/// <param name="Description">
|
||||
/// The first sentence of the page's body, or an empty string, in which case the
|
||||
/// tile shows only the title.
|
||||
/// </param>
|
||||
/// <param name="Icon">The icon name, see <see cref="TileIcons"/>.</param>
|
||||
public sealed record HomeTile(string Href, string Title, string Description, string Icon)
|
||||
{
|
||||
/// <summary>
|
||||
/// Builds one tile per header page except the home page itself.
|
||||
/// </summary>
|
||||
/// <param name="pages">All public pages.</param>
|
||||
/// <returns>The tiles, in menu order.</returns>
|
||||
/// <remarks>
|
||||
/// The tiles mirror the header menu (<see cref="NavBuilder.HeaderPages"/>), so a
|
||||
/// page an editor puts into the header appears here too, without any code. The
|
||||
/// home page is left out: a tile leading to the page it sits on would be useless.
|
||||
/// </remarks>
|
||||
public static IReadOnlyList<HomeTile> From(IReadOnlyList<Page> pages) =>
|
||||
[
|
||||
.. NavBuilder.HeaderPages(pages)
|
||||
.Where(page => !Page.IsHomeSlug(page.Slug))
|
||||
.Select(page => new HomeTile(
|
||||
NavBuilder.HrefFor(page.Slug),
|
||||
page.Title,
|
||||
Markdown.FirstSentence(page.Body),
|
||||
TileIcons.For(page.Slug))),
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
using System.Collections.Frozen;
|
||||
using Elternbeirat.Web.Shared;
|
||||
|
||||
namespace Elternbeirat.Web.Features.Home;
|
||||
|
||||
/// <summary>
|
||||
/// Chooses the icon of a home page tile from the page's slug.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Pure presentation, so it lives in code: the schema has no icon field, and adding
|
||||
/// one needs a schema change that cannot reach production yet. A page without an
|
||||
/// entry here (e.g. one an editor just created) gets <see cref="Default"/>, so a
|
||||
/// new page always shows up as a complete tile.
|
||||
/// </remarks>
|
||||
/// <seealso cref="IconSet"/>
|
||||
public static class TileIcons
|
||||
{
|
||||
/// <summary>
|
||||
/// The icon for a slug without its own entry: a generic page.
|
||||
/// </summary>
|
||||
public const string Default = "file-text";
|
||||
|
||||
/// <summary>
|
||||
/// Icon name per slug. Every value must be a name in <see cref="IconSet"/>.
|
||||
/// </summary>
|
||||
private static readonly FrozenDictionary<string, string> Icons =
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
["board"] = "users",
|
||||
["patrons"] = "heart-handshake",
|
||||
["faqs"] = "help-circle",
|
||||
["downloads"] = "download",
|
||||
["posts"] = "newspaper",
|
||||
["events"] = "calendar",
|
||||
["contact"] = "mail",
|
||||
}.ToFrozenDictionary(StringComparer.Ordinal);
|
||||
|
||||
/// <summary>
|
||||
/// Returns the icon name for a page.
|
||||
/// </summary>
|
||||
/// <param name="slug">The page's slug, e.g. <c>board</c>.</param>
|
||||
/// <returns>An icon name from <see cref="IconSet"/>; <see cref="Default"/> for an unknown slug.</returns>
|
||||
public static string For(string slug) =>
|
||||
Icons.GetValueOrDefault(slug, Default);
|
||||
}
|
||||
Reference in new issue
Block a user