Add post and event cards, shared calendar sheet and per-event ICS; rework past events and next-event card

This commit is contained in:
tleininger committed 2026-10-01 15:32:01 +02:00
1 parent d328adac5c
commit b2dc033de1
33 files changed
+1150 -206

No files matched your search

@@ -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;
}
}