Add post and event cards, shared calendar sheet and per-event ICS; rework past events and next-event card
This commit is contained in:
1 parent
d328adac5c
commit
b2dc033de1
33 files changed
+1150
-206
No files matched your search
@@ -6,16 +6,8 @@
|
||||
<ul class="event-list">
|
||||
@foreach (var ev in _upcomingEvents)
|
||||
{
|
||||
<li>
|
||||
<a class="card-link" href="/events">
|
||||
<span class="event-title">@ev.Title</span>
|
||||
<EventDate Value="ev" />
|
||||
@if (!string.IsNullOrWhiteSpace(ev.Location))
|
||||
{
|
||||
<span class="event-location">@ev.Location</span>
|
||||
}
|
||||
</a>
|
||||
</li>
|
||||
@* A teaser: each card leads to the events page with the full list. *@
|
||||
<li><EventCard Value="ev" Href="/events" /></li>
|
||||
}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/* Card styles for .event-list are shared with the full events page and live in the
|
||||
global app.css. Only the embed's spacing on the home page is specific here. */
|
||||
/* The card is EventCard and the list grid lives in the global app.css, shared with
|
||||
the full events page. Only the embed's spacing on the home page is specific here. */
|
||||
.embed-events {
|
||||
margin-top: var(--space-5);
|
||||
}
|
||||
@@ -6,12 +6,7 @@
|
||||
<ul class="post-list">
|
||||
@foreach (var post in _latest)
|
||||
{
|
||||
<li>
|
||||
<a class="card-link" href="@($"/posts/{post.Slug}")">
|
||||
<span class="post-title">@post.Title</span>
|
||||
<PostDate Value="post.Date" />
|
||||
</a>
|
||||
</li>
|
||||
<li><PostCard Value="post" /></li>
|
||||
}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/* Card styles for .post-list are shared with the full posts page and live in the
|
||||
global app.css. Only the embed's spacing on the home page is specific here. */
|
||||
/* The card is PostCard and the list grid lives in the global app.css, shared with
|
||||
the full posts page. Only the embed's spacing on the home page is specific here. */
|
||||
.embed-posts {
|
||||
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.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Shared by the full event list and the home-page events embed, so both render
|
||||
/// dates the same way.
|
||||
/// Shared by the full event list, the home-page events embed and the next-event
|
||||
/// 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>
|
||||
/// This is the event counterpart to
|
||||
/// <see cref="Posts.PostDateFormat"/>, which formats a plain
|
||||
@@ -27,6 +29,11 @@ public static class EventDateFormat
|
||||
/// </summary>
|
||||
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>
|
||||
/// Formats the date range of an event.
|
||||
/// </summary>
|
||||
@@ -54,13 +61,136 @@ public static class EventDateFormat
|
||||
(var start, null, var hasTime)
|
||||
=> Stamp(start, LongDate, hasTime),
|
||||
(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
|
||||
=> Stamp(start, LongDate, withTime: false),
|
||||
(var start, { } end, var 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>
|
||||
/// Formats a single point in time as a date with an optional time.
|
||||
/// </summary>
|
||||
|
||||
@@ -21,10 +21,9 @@
|
||||
}
|
||||
else
|
||||
{
|
||||
<p class="intro">
|
||||
Alle Termine des Elternbeirats auf einen Blick. Sie können den Kalender auch
|
||||
<a href="/events.ics">abonnieren</a> und in Ihrer Kalender-App automatisch
|
||||
aktuell halten.
|
||||
@* The page body above is the intro; the page only adds the subscribe button. *@
|
||||
<p class="events-subscribe">
|
||||
<a class="btn btn-secondary" href="/events.ics"><Icon Name="calendar-plus" Size="18" /> Kalender abonnieren</a>
|
||||
</p>
|
||||
|
||||
<h2>Kommende Termine</h2>
|
||||
@@ -38,41 +37,38 @@ else
|
||||
<ul class="event-list">
|
||||
@foreach (var ev in _upcomingEvents)
|
||||
{
|
||||
<li>
|
||||
<a class="card-link" href="/events">
|
||||
<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>
|
||||
@* No Href: a card linking to this very page would lead nowhere. *@
|
||||
<li><EventCard Value="ev" /></li>
|
||||
}
|
||||
</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)
|
||||
{
|
||||
<h2>Vergangene Termine</h2>
|
||||
<ul class="event-list event-list-past">
|
||||
@foreach (var ev in _pastEvents)
|
||||
<details class="past-events disclosure">
|
||||
<summary>Vergangene Termine anzeigen (@_pastEvents.Count)</summary>
|
||||
@foreach (var year in PastYears)
|
||||
{
|
||||
<li>
|
||||
<a class="card-link" href="/events">
|
||||
<span class="event-title">@ev.Title</span>
|
||||
<EventDate Value="ev" />
|
||||
@if (!string.IsNullOrWhiteSpace(ev.Location))
|
||||
{
|
||||
<span class="event-location">@ev.Location</span>
|
||||
}
|
||||
</a>
|
||||
</li>
|
||||
<h3 class="past-events-year">@year.Key</h3>
|
||||
<ul>
|
||||
@foreach (var ev in year)
|
||||
{
|
||||
var time = EventDateFormat.PastTime(ev);
|
||||
<li>
|
||||
<span class="past-event-date">@EventDateFormat.PastDays(ev)</span>
|
||||
<span class="past-event-title">
|
||||
@ev.Title
|
||||
@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.
|
||||
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.
|
||||
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
|
||||
home-page embed and live in the global app.css. Nothing page-specific here. */
|
||||
/* The card grid (.event-list) is shared with the home-page embed and lives in the
|
||||
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;
|
||||
}
|
||||
@@ -27,7 +27,7 @@ else
|
||||
{
|
||||
@foreach (var topic in _topics)
|
||||
{
|
||||
<details class="faq-topic">
|
||||
<details class="faq-topic disclosure">
|
||||
<summary>@topic.Title</summary>
|
||||
@if (!string.IsNullOrWhiteSpace(topic.Intro))
|
||||
{
|
||||
|
||||
@@ -29,21 +29,24 @@
|
||||
|
||||
@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. *@
|
||||
@* As in EventCard: the calendar sheet shows the date and is hidden from
|
||||
screen readers, who get the full date from <EventDate> instead. Visible
|
||||
beside the sheet is only what it cannot show: the time, the end. *@
|
||||
var span = EventDateFormat.Span(_nextEvent);
|
||||
<a class="next-event" href="/events">
|
||||
<span class="next-event-label">Nächster Termin</span>
|
||||
<span class="next-event-row">
|
||||
<span class="calendar-sheet" aria-hidden="true">
|
||||
<span class="calendar-month">@NextEventMonth</span>
|
||||
<span class="calendar-day">@NextEventDay</span>
|
||||
</span>
|
||||
<CalendarSheet Value="_nextEvent.Start" />
|
||||
<span class="next-event-details">
|
||||
<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))
|
||||
{
|
||||
<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>
|
||||
|
||||
@@ -46,14 +46,6 @@ public partial class Home
|
||||
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;
|
||||
|
||||
@@ -136,38 +136,6 @@
|
||||
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 */
|
||||
}
|
||||
@@ -180,7 +148,9 @@
|
||||
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;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
@@ -189,6 +159,10 @@
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.next-event-meta ::deep .icon {
|
||||
color: var(--color-accent-dark);
|
||||
}
|
||||
|
||||
/* --- Tiles --------------------------------------------------------------------
|
||||
One card per header page. Fixed columns instead of auto-fill: two on a phone,
|
||||
three from tablet width, so the six pages always fill whole rows and never leave
|
||||
|
||||
@@ -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
|
||||
{
|
||||
<ul class="post-list">
|
||||
@foreach (var post in _posts)
|
||||
@* The newest post (GetPostsAsync sorts newest first) opens the page as a large
|
||||
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>
|
||||
<a class="card-link" href="@($"/posts/{post.Slug}")">
|
||||
<span class="post-title">@post.Title</span>
|
||||
<PostDate Value="post.Date" />
|
||||
</a>
|
||||
</li>
|
||||
<li class="@(i == 0 ? "post-list-featured" : null)"><PostCard Value="_posts[i]" Featured="i == 0" /></li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
@@ -1,2 +1,12 @@
|
||||
/* The .post-list card styles are shared with the home-page embed and live in the
|
||||
global app.css. This page needs no extra styling of its own. */
|
||||
/* The card is PostCard and the base list grid lives in the global app.css, shared
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -69,6 +69,28 @@ app.MapGet("/events.ics", async (PocketBaseClient pocketBase, CancellationToken
|
||||
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
|
||||
// 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
|
||||
|
||||
@@ -55,6 +55,12 @@ public static partial class Markdown
|
||||
/// </summary>
|
||||
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 =
|
||||
new MarkdownPipelineBuilder()
|
||||
.UseCustomContainers()
|
||||
@@ -120,6 +126,53 @@ public static partial class Markdown
|
||||
? UpToSentenceEnd(PlainText(inline)).TrimEnd(':', ' ')
|
||||
: "";
|
||||
|
||||
/// <summary>
|
||||
/// Builds a short plain-text teaser from a Markdown text, e.g. for a post card.
|
||||
/// </summary>
|
||||
/// <param name="markdown">The editor's Markdown; may be <see langword="null"/>.</param>
|
||||
/// <param name="maxLength">
|
||||
/// The longest teaser, in characters, before the ellipsis. Defaults to
|
||||
/// <see cref="TeaserLength"/>.
|
||||
/// </param>
|
||||
/// <returns>
|
||||
/// The text of all paragraphs (including those in lists, quotes and <c>:::</c>
|
||||
/// blocks), with all Markdown removed and whitespace collapsed. A longer text is
|
||||
/// cut at the last word boundary within <paramref name="maxLength"/> and ends
|
||||
/// in <c>…</c>. An empty string when there is no paragraph text at all.
|
||||
/// </returns>
|
||||
/// <exception cref="ArgumentOutOfRangeException">
|
||||
/// <paramref name="maxLength"/> is zero or negative.
|
||||
/// </exception>
|
||||
/// <remarks>
|
||||
/// Headings are skipped: on a card the title already heads the teaser. Unlike
|
||||
/// <see cref="FirstSentence"/> the teaser does not stop at the first sentence,
|
||||
/// because a post often opens with a short line ("Liebe Eltern,") that says
|
||||
/// nothing on its own.
|
||||
/// <para>
|
||||
/// Punctuation left dangling at the cut (<c>,</c>, <c>.</c>, <c>:</c> …) is
|
||||
/// dropped, so the teaser never ends in <c>,…</c>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <example>
|
||||
/// <code>
|
||||
/// Markdown.Teaser("## Rückblick\n\nDer **Basar** war ein voller Erfolg.", 20);
|
||||
/// // "Der Basar war ein…"
|
||||
/// </code>
|
||||
/// </example>
|
||||
public static string Teaser(string? markdown, int maxLength = TeaserLength) =>
|
||||
maxLength <= 0
|
||||
? throw new ArgumentOutOfRangeException(nameof(maxLength), maxLength, "The teaser needs room for at least one character.")
|
||||
: string.IsNullOrWhiteSpace(markdown)
|
||||
? ""
|
||||
: Shorten(
|
||||
string.Join(
|
||||
' ',
|
||||
Parse(markdown)
|
||||
.Descendants<ParagraphBlock>()
|
||||
.Select(paragraph => paragraph.Inline is { } inline ? PlainText(inline) : "")
|
||||
.Where(text => text.Length > 0)),
|
||||
maxLength);
|
||||
|
||||
/// <summary>
|
||||
/// Parses Markdown with the site's pipeline, for callers that take the document
|
||||
/// apart before rendering it (see <see cref="Render"/>).
|
||||
@@ -192,6 +245,26 @@ public static partial class Markdown
|
||||
private static string UpToSentenceEnd(string text) =>
|
||||
SentenceEnd().Match(text) is { Success: true } end ? text[..(end.Index + 1)] : text;
|
||||
|
||||
/// <summary>
|
||||
/// Cuts a text to a word boundary and marks the cut.
|
||||
/// </summary>
|
||||
/// <param name="text">Plain text.</param>
|
||||
/// <param name="maxLength">The longest result before the ellipsis.</param>
|
||||
/// <returns>
|
||||
/// The text unchanged if it fits; otherwise everything up to the last space
|
||||
/// within <paramref name="maxLength"/>, trailing punctuation removed, plus
|
||||
/// <c>…</c>. A single word longer than <paramref name="maxLength"/> is cut hard.
|
||||
/// </returns>
|
||||
/// <remarks>
|
||||
/// The space is searched one character past the limit: if the text breaks
|
||||
/// exactly at <paramref name="maxLength"/>, the last whole word still fits.
|
||||
/// </remarks>
|
||||
private static string Shorten(string text, int maxLength) =>
|
||||
text.Length <= maxLength
|
||||
? text
|
||||
: (text[..(maxLength + 1)].LastIndexOf(' ') is var space and > 0 ? text[..space] : text[..maxLength])
|
||||
.TrimEnd(' ', ',', ';', ':', '.', '-', '–') + "…";
|
||||
|
||||
/// <summary>
|
||||
/// Matches the end mark of a sentence: <c>.</c>, <c>!</c> or <c>?</c> before a
|
||||
/// space or the end, unless it follows a lone letter (<c>z.</c>) or a number
|
||||
|
||||
@@ -54,6 +54,11 @@
|
||||
not --color-accent, so white text stays readable across the whole sweep. */
|
||||
--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
|
||||
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
|
||||
@@ -165,6 +170,7 @@
|
||||
yellow would glare on the dark page */
|
||||
|
||||
--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
|
||||
text, calm on the dark page */
|
||||
@@ -289,6 +295,22 @@ h1:focus {
|
||||
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
|
||||
letters instead of the baseline, and never squeezed when they share a flex row
|
||||
with a long label. */
|
||||
@@ -400,12 +422,12 @@ h1:focus {
|
||||
}
|
||||
}
|
||||
|
||||
/* --- Shared card styles for content lists --------------------------------------
|
||||
Posts, events and FAQs appear both on their own page (PostList/EventList/
|
||||
FaqList) AND embedded on the home page (the *Embed components), with the same
|
||||
class names. These rules live here (global) so both places share one look
|
||||
instead of duplicating the CSS per component. The card look: a white surface
|
||||
lifted off the tinted page background with a soft shadow and a gentle hover. */
|
||||
/* --- Lists of post and event cards ------------------------------------------------
|
||||
Posts and events appear both on their own page (PostList/EventList) AND embedded
|
||||
on the home page (the *Embed components). The cards themselves are components
|
||||
(PostCard, EventCard) with their own scoped styles; only the list around them is
|
||||
shared here: no bullets, one column with even gaps. A page may widen the grid
|
||||
(the posts page goes to two columns). */
|
||||
|
||||
.post-list,
|
||||
.event-list {
|
||||
@@ -416,29 +438,6 @@ h1:focus {
|
||||
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
|
||||
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
|
||||
@@ -456,7 +455,10 @@ h1:focus {
|
||||
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;
|
||||
font-size: 1.15rem;
|
||||
font-weight: 600;
|
||||
@@ -467,17 +469,17 @@ h1:focus {
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.faq-topic > summary::-webkit-details-marker {
|
||||
.disclosure > summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.faq-topic > summary::before {
|
||||
.disclosure > summary::before {
|
||||
content: "▸";
|
||||
color: var(--color-accent-dark);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.faq-topic[open] > summary::before {
|
||||
.disclosure[open] > summary::before {
|
||||
content: "▾";
|
||||
}
|
||||
|
||||
@@ -491,57 +493,6 @@ h1:focus {
|
||||
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-item {
|
||||
background: var(--color-bg);
|
||||
|
||||
Reference in new issue
Block a user