Compare commits
10
Commits
0257b2e30b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
53cee04396 | ||
|
|
ee69892058 | ||
|
|
a847507b46 | ||
|
|
7d09233ca2 | ||
|
|
8e4333b3ac | ||
|
|
ebb3b387c5 | ||
|
|
7e56e83953 | ||
|
|
ed6aa5d5ec | ||
|
|
ba65b18d29 | ||
|
|
ae362639e3 |
No files matched your search
@@ -0,0 +1,73 @@
|
|||||||
|
using Elternbeirat.Web.Shared;
|
||||||
|
|
||||||
|
namespace Elternbeirat.Web.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Unit tests for the health cache behind the maintenance gate. The probe is a fake
|
||||||
|
/// that counts its calls, so no PocketBase is needed.
|
||||||
|
/// </summary>
|
||||||
|
public class PocketBaseHealthCacheTests
|
||||||
|
{
|
||||||
|
private readonly SteppingClock _clock = new(new DateTimeOffset(2026, 10, 1, 10, 0, 0, TimeSpan.Zero));
|
||||||
|
private int _probes;
|
||||||
|
|
||||||
|
private Func<CancellationToken, Task<bool>> Probe(bool healthy) =>
|
||||||
|
_ =>
|
||||||
|
{
|
||||||
|
_probes++;
|
||||||
|
return Task.FromResult(healthy);
|
||||||
|
};
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Result_is_reused_within_the_lifetime()
|
||||||
|
{
|
||||||
|
var cache = new PocketBaseHealthCache(_clock);
|
||||||
|
|
||||||
|
await cache.IsHealthyAsync(Probe(healthy: true), CancellationToken.None);
|
||||||
|
_clock.Now += PocketBaseHealthCache.Lifetime - TimeSpan.FromSeconds(1);
|
||||||
|
var healthy = await cache.IsHealthyAsync(Probe(healthy: false), CancellationToken.None);
|
||||||
|
|
||||||
|
healthy.ShouldBeTrue();
|
||||||
|
_probes.ShouldBe(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Stale_result_is_probed_again()
|
||||||
|
{
|
||||||
|
// The way back after an outage: once the lifetime is over, the new answer
|
||||||
|
// replaces the old one.
|
||||||
|
var cache = new PocketBaseHealthCache(_clock);
|
||||||
|
|
||||||
|
await cache.IsHealthyAsync(Probe(healthy: false), CancellationToken.None);
|
||||||
|
_clock.Now += PocketBaseHealthCache.Lifetime;
|
||||||
|
var healthy = await cache.IsHealthyAsync(Probe(healthy: true), CancellationToken.None);
|
||||||
|
|
||||||
|
healthy.ShouldBeTrue();
|
||||||
|
_probes.ShouldBe(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Unhealthy_result_is_cached_too()
|
||||||
|
{
|
||||||
|
// While PocketBase is down, not every request should wait for a failing probe.
|
||||||
|
var cache = new PocketBaseHealthCache(_clock);
|
||||||
|
|
||||||
|
await cache.IsHealthyAsync(Probe(healthy: false), CancellationToken.None);
|
||||||
|
var healthy = await cache.IsHealthyAsync(Probe(healthy: true), CancellationToken.None);
|
||||||
|
|
||||||
|
healthy.ShouldBeFalse();
|
||||||
|
_probes.ShouldBe(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A clock the test moves forward by hand, to step past the cache lifetime without
|
||||||
|
/// waiting.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="start">The moment the clock shows at first.</param>
|
||||||
|
private sealed class SteppingClock(DateTimeOffset start) : TimeProvider
|
||||||
|
{
|
||||||
|
public DateTimeOffset Now { get; set; } = start;
|
||||||
|
|
||||||
|
public override DateTimeOffset GetUtcNow() => Now;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
using Elternbeirat.Contracts;
|
||||||
|
using Elternbeirat.Web.Features.Posts;
|
||||||
|
|
||||||
|
namespace Elternbeirat.Web.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Unit tests for the older/newer post next to a post. The list comes newest
|
||||||
|
/// first, as from PocketBase; "previous" is the older post. Pure logic, no
|
||||||
|
/// PocketBase needed.
|
||||||
|
/// </summary>
|
||||||
|
public class PostNeighborsTests
|
||||||
|
{
|
||||||
|
// Newest first, like PocketBaseClient.GetPostsAsync.
|
||||||
|
private static readonly Post[] Posts = [Post("march"), Post("february"), Post("january")];
|
||||||
|
|
||||||
|
private static Post Post(string slug) =>
|
||||||
|
new() { Id = slug, Title = slug, Slug = slug };
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Middle_post_has_the_older_as_previous_and_the_newer_as_next()
|
||||||
|
{
|
||||||
|
var neighbors = PostNeighbors.Of(Posts, "february");
|
||||||
|
|
||||||
|
neighbors.Previous?.Slug.ShouldBe("january");
|
||||||
|
neighbors.Next?.Slug.ShouldBe("march");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Newest_post_has_no_next()
|
||||||
|
{
|
||||||
|
var neighbors = PostNeighbors.Of(Posts, "march");
|
||||||
|
|
||||||
|
neighbors.Previous?.Slug.ShouldBe("february");
|
||||||
|
neighbors.Next.ShouldBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Oldest_post_has_no_previous()
|
||||||
|
{
|
||||||
|
var neighbors = PostNeighbors.Of(Posts, "january");
|
||||||
|
|
||||||
|
neighbors.Previous.ShouldBeNull();
|
||||||
|
neighbors.Next?.Slug.ShouldBe("february");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Only_post_has_no_neighbors() =>
|
||||||
|
PostNeighbors.Of([Post("only")], "only").ShouldBe(new PostNeighbors(null, null));
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Unknown_slug_has_no_neighbors() =>
|
||||||
|
PostNeighbors.Of(Posts, "december").ShouldBe(new PostNeighbors(null, null));
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Slug_is_compared_case_insensitively() =>
|
||||||
|
PostNeighbors.Of(Posts, "February").Next?.Slug.ShouldBe("march");
|
||||||
|
}
|
||||||
@@ -60,7 +60,16 @@ public sealed partial class RouteSmokeTests : IDisposable
|
|||||||
/// Every route that a visitor can reach through the navigation, the FAQ hub
|
/// Every route that a visitor can reach through the navigation, the FAQ hub
|
||||||
/// or the news section. The post slugs match the fixture's seed.
|
/// or the news section. The post slugs match the fixture's seed.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static TheoryData<string> KnownRoutes =>
|
public static TheoryData<string> KnownRoutes => [.. Routes];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The <see cref="KnownRoutes"/> that render an HTML page, i.e. all but the
|
||||||
|
/// calendar feed.
|
||||||
|
/// </summary>
|
||||||
|
public static TheoryData<string> PageRoutes =>
|
||||||
|
[.. Routes.Where(route => !route.EndsWith(".ics", StringComparison.Ordinal))];
|
||||||
|
|
||||||
|
private static readonly string[] Routes =
|
||||||
[
|
[
|
||||||
"/",
|
"/",
|
||||||
"/board",
|
"/board",
|
||||||
@@ -299,8 +308,64 @@ public sealed partial class RouteSmokeTests : IDisposable
|
|||||||
var response = await client.GetAsync(new Uri("/health", UriKind.Relative));
|
var response = await client.GetAsync(new Uri("/health", UriKind.Relative));
|
||||||
|
|
||||||
response.StatusCode.ShouldBe(HttpStatusCode.ServiceUnavailable);
|
response.StatusCode.ShouldBe(HttpStatusCode.ServiceUnavailable);
|
||||||
|
(await response.Content.ReadAsStringAsync()).ShouldBe("PocketBase unreachable");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("/")]
|
||||||
|
[InlineData("/board")]
|
||||||
|
[InlineData("/posts/new-board")]
|
||||||
|
public async Task Page_shows_maintenance_page_when_PocketBase_is_unreachable(string route)
|
||||||
|
{
|
||||||
|
// The maintenance gate: with PocketBase down, a page is not rendered half-empty
|
||||||
|
// but answered with the static maintenance.html, a 503 and a hint when to retry.
|
||||||
|
await using var factory = UnreachablePocketBase();
|
||||||
|
var client = factory.CreateClient();
|
||||||
|
|
||||||
|
var response = await client.GetAsync(new Uri(route, UriKind.Relative));
|
||||||
|
|
||||||
|
response.StatusCode.ShouldBe(HttpStatusCode.ServiceUnavailable);
|
||||||
|
response.Headers.RetryAfter.ShouldNotBeNull();
|
||||||
|
(await response.Content.ReadAsStringAsync()).ShouldContain("Die Seite ist gerade nicht erreichbar");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Calendar_feed_stays_an_empty_calendar_when_PocketBase_is_unreachable()
|
||||||
|
{
|
||||||
|
// Not a page, so the gate lets it through: subscribed calendar apps keep
|
||||||
|
// getting a valid (empty) calendar instead of an HTML page.
|
||||||
|
await using var factory = UnreachablePocketBase();
|
||||||
|
var client = factory.CreateClient();
|
||||||
|
|
||||||
|
var response = await client.GetAsync(new Uri("/events.ics", UriKind.Relative));
|
||||||
|
|
||||||
|
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||||
|
(await response.Content.ReadAsStringAsync()).ShouldContain("BEGIN:VCALENDAR");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Maintenance_page_and_its_font_stay_reachable_when_PocketBase_is_unreachable()
|
||||||
|
{
|
||||||
|
// Static files are not gated: the maintenance page loads its self-hosted font
|
||||||
|
// from /fonts while PocketBase is down.
|
||||||
|
await using var factory = UnreachablePocketBase();
|
||||||
|
var client = factory.CreateClient();
|
||||||
|
|
||||||
|
var page = await client.GetAsync(new Uri("/maintenance.html", UriKind.Relative));
|
||||||
|
var font = await client.GetAsync(new Uri("/fonts/nunito-latin-wght.woff2", UriKind.Relative));
|
||||||
|
|
||||||
|
page.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||||
|
font.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A throwaway app pointed at a dead address: nothing listens on port 1, so every
|
||||||
|
/// PocketBase call is refused at once, without a timeout wait.
|
||||||
|
/// </summary>
|
||||||
|
private WebApplicationFactory<Program> UnreachablePocketBase() =>
|
||||||
|
_baseFactory.WithWebHostBuilder(builder =>
|
||||||
|
builder.UseSetting("PocketBase:BaseUrl", "http://localhost:1"));
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Home_hero_takes_title_and_buttons_from_the_body()
|
public async Task Home_hero_takes_title_and_buttons_from_the_body()
|
||||||
{
|
{
|
||||||
@@ -453,6 +518,71 @@ public sealed partial class RouteSmokeTests : IDisposable
|
|||||||
visible.ShouldNotContain("Oktober 2026");
|
visible.ShouldNotContain("Oktober 2026");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Post_page_has_the_title_as_its_only_h1()
|
||||||
|
{
|
||||||
|
var client = _factory.CreateClient();
|
||||||
|
|
||||||
|
var html = await client.GetStringAsync(new Uri("/posts/new-board", UriKind.Relative));
|
||||||
|
|
||||||
|
Regex.Count(html, "<h1[ >]").ShouldBe(1);
|
||||||
|
html.ShouldContain(">Neuer Vorstand</h1>");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Newest_post_links_only_to_the_older_one()
|
||||||
|
{
|
||||||
|
// The fixture has two posts: new-board (newer) and new-hall (older).
|
||||||
|
// "Vorheriger Beitrag" is the older post, so the newest post has no "next".
|
||||||
|
var client = _factory.CreateClient();
|
||||||
|
|
||||||
|
var html = await client.GetStringAsync(new Uri("/posts/new-board", UriKind.Relative));
|
||||||
|
|
||||||
|
html.ShouldContain("href=\"/posts/new-hall\" rel=\"prev\"");
|
||||||
|
html.ShouldNotContain("rel=\"next\"");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Oldest_post_links_only_to_the_newer_one()
|
||||||
|
{
|
||||||
|
var client = _factory.CreateClient();
|
||||||
|
|
||||||
|
var html = await client.GetStringAsync(new Uri("/posts/new-hall", UriKind.Relative));
|
||||||
|
|
||||||
|
html.ShouldContain("href=\"/posts/new-board\" rel=\"next\"");
|
||||||
|
html.ShouldNotContain("rel=\"prev\"");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[MemberData(nameof(PageRoutes))]
|
||||||
|
public async Task Every_page_starts_with_a_skip_link_to_its_own_content(string route)
|
||||||
|
{
|
||||||
|
// The skip link must carry the page's own path: with <base href="/"> a bare
|
||||||
|
// "#main" would lead back to the home page. It must also bypass Blazor's
|
||||||
|
// enhanced navigation, which scrolls but does not move the focus.
|
||||||
|
var client = _factory.CreateClient();
|
||||||
|
|
||||||
|
var html = await client.GetStringAsync(new Uri(route, UriKind.Relative));
|
||||||
|
|
||||||
|
html.ShouldContain($"""<a class="skip-link" href="{route}#main" data-enhance-nav="false" """);
|
||||||
|
html.ShouldContain("Zum Inhalt springen</a>");
|
||||||
|
html.ShouldContain("""<main id="main" """);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Menu_toggle_is_a_named_checkbox_tied_to_the_menu()
|
||||||
|
{
|
||||||
|
// The CSS-only hamburger: the checkbox is what keyboard and screen-reader
|
||||||
|
// users operate, so it needs a name and must point at the menu it opens.
|
||||||
|
var client = _factory.CreateClient();
|
||||||
|
|
||||||
|
var html = await client.GetStringAsync(new Uri("/", UriKind.Relative));
|
||||||
|
|
||||||
|
html.ShouldContain("aria-controls=\"main-nav\"");
|
||||||
|
html.ShouldContain("Hauptmenü anzeigen</span>");
|
||||||
|
html.ShouldContain("<nav id=\"main-nav\"");
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Matches a resource reference that points at an absolute URL, with or without
|
/// Matches a resource reference that points at an absolute URL, with or without
|
||||||
/// scheme (<c>https://</c>, <c>http://</c> or protocol-relative <c>//</c>): the
|
/// scheme (<c>https://</c>, <c>http://</c> or protocol-relative <c>//</c>): the
|
||||||
|
|||||||
@@ -6,6 +6,15 @@
|
|||||||
immediately), it pins to the top and stays there while the page scrolls. The
|
immediately), it pins to the top and stays there while the page scrolls. The
|
||||||
footer is kept at the bottom on short pages by a min-height on <main>, not by a
|
footer is kept at the bottom on short pages by a min-height on <main>, not by a
|
||||||
flex wrapper -- see MainLayout.razor.css. *@
|
flex wrapper -- see MainLayout.razor.css. *@
|
||||||
|
|
||||||
|
@* Skip link: the first stop for the Tab key, straight past the menu to the content.
|
||||||
|
The href carries the current path because of <base href="/"> in App.razor: a
|
||||||
|
bare "#main" would resolve to "/#main" and lead every subpage back home.
|
||||||
|
data-enhance-nav="false" leaves the jump to the browser: blazor.web.js would
|
||||||
|
otherwise take the click, only scroll to <main> and keep the focus on the link,
|
||||||
|
so the next Tab would land in the header menu again. *@
|
||||||
|
<a class="skip-link" href="@SkipLinkHref" data-enhance-nav="false">Zum Inhalt springen</a>
|
||||||
|
|
||||||
<header class="site-header">
|
<header class="site-header">
|
||||||
<div class="header-inner">
|
<div class="header-inner">
|
||||||
@* Two-line lettering next to the signet: the body's name large, the
|
@* Two-line lettering next to the signet: the body's name large, the
|
||||||
@@ -18,16 +27,23 @@
|
|||||||
</span>
|
</span>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
@* CSS-only mobile menu: the hidden checkbox holds the open/closed
|
@* CSS-only mobile menu: the checkbox holds the open/closed state, the
|
||||||
state, the label is the hamburger button, and :checked reveals the
|
label is the hamburger button, and :checked reveals the nav below (see
|
||||||
nav below (see MainLayout.razor.css). No JavaScript, no @@rendermode
|
MainLayout.razor.css). No JavaScript, no @@rendermode -- works under
|
||||||
-- works under static SSR. *@
|
static SSR. The checkbox is only visually hidden on a phone, not
|
||||||
<input type="checkbox" id="nav-toggle" class="nav-toggle" />
|
removed, so it is what keyboard and screen-reader users operate: Tab
|
||||||
<label for="nav-toggle" class="nav-toggle-label" aria-label="Menü öffnen und schließen">
|
reaches it, Space toggles it, and its checked state is the menu's
|
||||||
|
open/closed state, announced as such ("Hauptmenü anzeigen,
|
||||||
|
Kontrollkästchen, aktiviert"). Its name is the label's hidden text;
|
||||||
|
aria-controls ties it to the menu it opens. aria-expanded would need
|
||||||
|
JavaScript to keep it in sync, the native checked state does not. *@
|
||||||
|
<input type="checkbox" id="nav-toggle" class="nav-toggle visually-hidden" aria-controls="main-nav" />
|
||||||
|
<label for="nav-toggle" class="nav-toggle-label">
|
||||||
<span class="nav-toggle-bar"></span>
|
<span class="nav-toggle-bar"></span>
|
||||||
|
<span class="visually-hidden">Hauptmenü anzeigen</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<nav class="main-nav" aria-label="Hauptmenü">
|
<nav id="main-nav" class="main-nav" aria-label="Hauptmenü">
|
||||||
@* The home link comes from PocketBase like every other entry (the
|
@* The home link comes from PocketBase like every other entry (the
|
||||||
"home" page carries location "header"), so it is rendered by the
|
"home" page carries location "header"), so it is rendered by the
|
||||||
loop below -- no separate hard-coded home link, which would show
|
loop below -- no separate hard-coded home link, which would show
|
||||||
@@ -40,7 +56,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main class="content">
|
@* The skip link's target. tabindex="-1" makes it focusable by the link alone
|
||||||
|
(not by Tab), so after the jump the next Tab continues inside the content. *@
|
||||||
|
<main id="main" class="content" tabindex="-1">
|
||||||
@Body
|
@Body
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,12 @@ public partial class MainLayout
|
|||||||
private IReadOnlyList<NavLinkItem> _header = [];
|
private IReadOnlyList<NavLinkItem> _header = [];
|
||||||
private IReadOnlyList<NavLinkItem> _footer = [];
|
private IReadOnlyList<NavLinkItem> _footer = [];
|
||||||
|
|
||||||
|
// The skip link's target on this very page: path and query of the request plus
|
||||||
|
// the fragment. See the comment on the skip link in the markup for why it cannot
|
||||||
|
// be a bare "#main".
|
||||||
|
private string SkipLinkHref =>
|
||||||
|
$"{HttpContext?.Request.PathBase}{HttpContext?.Request.Path}{HttpContext?.Request.QueryString}#main";
|
||||||
|
|
||||||
// Berlin's year, not the server's (UTC) -- see FooterYear.
|
// Berlin's year, not the server's (UTC) -- see FooterYear.
|
||||||
private readonly int _year = FooterYear.For(TimeProvider.System.GetUtcNow());
|
private readonly int _year = FooterYear.For(TimeProvider.System.GetUtcNow());
|
||||||
|
|
||||||
|
|||||||
@@ -62,6 +62,12 @@
|
|||||||
color: var(--color-on-header);
|
color: var(--color-on-header);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* On the blue gradient the global brand-blue focus ring would vanish; white
|
||||||
|
stays visible on both ends of it. */
|
||||||
|
.brand:focus-visible {
|
||||||
|
outline-color: var(--color-focus-inverse);
|
||||||
|
}
|
||||||
|
|
||||||
/* The signet lives in its own component (Signet.razor), so its <svg> carries that
|
/* The signet lives in its own component (Signet.razor), so its <svg> carries that
|
||||||
component's scope attribute, not this one's; ::deep reaches it from here. */
|
component's scope attribute, not this one's; ::deep reaches it from here. */
|
||||||
.brand ::deep .signet {
|
.brand ::deep .signet {
|
||||||
@@ -97,7 +103,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* The hamburger button and its state checkbox are hidden on wide screens; the
|
/* The hamburger button and its state checkbox are hidden on wide screens; the
|
||||||
media query below turns them on for narrow ones. */
|
media query below turns them on for narrow ones. display:none (beating the
|
||||||
|
global .visually-hidden the checkbox also carries) takes the checkbox out of the
|
||||||
|
tab order here, where there is no menu to open. */
|
||||||
.nav-toggle,
|
.nav-toggle,
|
||||||
.nav-toggle-label {
|
.nav-toggle-label {
|
||||||
display: none;
|
display: none;
|
||||||
@@ -205,6 +213,11 @@
|
|||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Same reason as the brand link in the header: a light ring on the dark band. */
|
||||||
|
.footer-nav a:focus-visible {
|
||||||
|
outline-color: var(--color-focus-inverse);
|
||||||
|
}
|
||||||
|
|
||||||
/* Copyright: a thin rule separates it from the columns. The rule spans the content
|
/* Copyright: a thin rule separates it from the columns. The rule spans the content
|
||||||
width, not the whole window, so it lines up with the columns above. */
|
width, not the whole window, so it lines up with the columns above. */
|
||||||
.footer-copyright {
|
.footer-copyright {
|
||||||
@@ -228,6 +241,19 @@
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The checkbox comes back as a real, focusable control -- only visually hidden
|
||||||
|
(global .visually-hidden), so Tab stops on it and Space toggles the menu. It
|
||||||
|
lives under the label, so the focus ring is drawn there instead. */
|
||||||
|
.nav-toggle {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-toggle:focus-visible + .nav-toggle-label {
|
||||||
|
outline: 3px solid var(--color-focus-inverse);
|
||||||
|
outline-offset: 2px;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
}
|
||||||
|
|
||||||
/* Show the hamburger button. */
|
/* Show the hamburger button. */
|
||||||
.nav-toggle-label {
|
.nav-toggle-label {
|
||||||
display: block;
|
display: block;
|
||||||
@@ -271,20 +297,29 @@
|
|||||||
transform: translateY(-2px) rotate(-45deg); /* back up by its own height */
|
transform: translateY(-2px) rotate(-45deg); /* back up by its own height */
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The nav takes its own row and is hidden until the checkbox is checked. */
|
/* The nav takes its own row and is hidden until the checkbox is checked.
|
||||||
|
max-height:0 alone would only hide it from the eye: its links would still
|
||||||
|
be Tab stops and still be read out. visibility:hidden takes them out of
|
||||||
|
both. Transitioning visibility delays the switch to hidden until the fold
|
||||||
|
has finished, so closing still animates. Opening uses the transition of the
|
||||||
|
:checked rule below, which leaves visibility out: the links must be Tab
|
||||||
|
stops the moment Space is pressed, not 0.2s later. */
|
||||||
.main-nav {
|
.main-nav {
|
||||||
flex-basis: 100%;
|
flex-basis: 100%;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0;
|
gap: 0;
|
||||||
max-height: 0;
|
max-height: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
transition: max-height 0.2s ease;
|
visibility: hidden;
|
||||||
|
transition: max-height 0.2s ease, visibility 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Checked state: reveal the stacked links. The max-height caps the open menu
|
/* Checked state: reveal the stacked links. The max-height caps the open menu
|
||||||
to the space below the sticky header; a longer list scrolls inside it
|
to the space below the sticky header; a longer list scrolls inside it
|
||||||
instead of running off the screen. */
|
instead of running off the screen. */
|
||||||
.nav-toggle:checked ~ .main-nav {
|
.nav-toggle:checked ~ .main-nav {
|
||||||
|
visibility: visible;
|
||||||
|
transition: max-height 0.2s ease;
|
||||||
max-height: 70vh;
|
max-height: 70vh;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
margin-top: 0.5rem;
|
margin-top: 0.5rem;
|
||||||
|
|||||||
@@ -27,11 +27,6 @@
|
|||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.event-card-link:focus-visible {
|
|
||||||
outline: 3px solid var(--color-focus);
|
|
||||||
outline-offset: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.event-details {
|
.event-details {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -1,14 +1,29 @@
|
|||||||
/* The card grid (.event-list) is shared with the home-page embed and lives in the
|
/* 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. */
|
global app.css; the card itself is EventCard. Only the page's own parts are here. */
|
||||||
|
|
||||||
|
/* Same centred 42rem reading column as a content page (ContentPage.razor.css), so
|
||||||
|
every page of the site shares one column under the header. */
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
article,
|
||||||
|
p,
|
||||||
|
.event-list,
|
||||||
|
.past-events {
|
||||||
|
max-width: 42rem;
|
||||||
|
margin-inline: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* auto, not 0: this rule outweighs the column rule above and would otherwise pull
|
||||||
|
the button back to the left edge of the page. */
|
||||||
.events-subscribe {
|
.events-subscribe {
|
||||||
margin: var(--space-4) 0;
|
margin: var(--space-4) auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Past events: one folded-up card. Opened, a compact list grouped by year,
|
/* 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
|
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. */
|
the shared .disclosure rule in app.css, the same as on the FAQ topics. */
|
||||||
.past-events {
|
.past-events {
|
||||||
|
box-sizing: border-box; /* padding inside the 42rem, flush with the text */
|
||||||
margin-top: var(--space-5);
|
margin-top: var(--space-5);
|
||||||
padding: var(--space-3) var(--space-4);
|
padding: var(--space-3) var(--space-4);
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
/* Same centred 42rem reading column as a content page (ContentPage.razor.css), so
|
||||||
|
every page of the site shares one column under the header. The topic cards and
|
||||||
|
the questions inside them live in the global app.css. */
|
||||||
|
h1,
|
||||||
|
article,
|
||||||
|
p,
|
||||||
|
.faq-topic {
|
||||||
|
box-sizing: border-box; /* padding inside the 42rem, flush with the text */
|
||||||
|
max-width: 42rem;
|
||||||
|
margin-inline: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The intro's last paragraph drops its margin (app.css), so the first card would
|
||||||
|
sit right under the text; this gives it the same air as between sections. */
|
||||||
|
article {
|
||||||
|
margin-bottom: var(--space-5);
|
||||||
|
}
|
||||||
@@ -35,8 +35,8 @@ public partial class Home
|
|||||||
private HttpContext? HttpContext { get; set; }
|
private HttpContext? HttpContext { get; set; }
|
||||||
|
|
||||||
// Heading, intro and buttons come from the "home" page's body. Until it is loaded
|
// 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
|
// -- and for good if PocketBase fails during this request -- the static fallback
|
||||||
// the start page always has a title.
|
// heading stands, so the start page always has a title.
|
||||||
private HomeIntro _intro = HomeIntro.Fallback;
|
private HomeIntro _intro = HomeIntro.Fallback;
|
||||||
|
|
||||||
// The hero, the tiles and the next-event card are fixed structure of the start
|
// The hero, the tiles and the next-event card are fixed structure of the start
|
||||||
@@ -53,8 +53,12 @@ public partial class Home
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Pages and events load side by side and fail on their own: without events the
|
// 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
|
// page loses only the card, without pages only text and tiles. A PocketBase that
|
||||||
// start page stays a 200 rather than a 503 -- it is degraded, not broken.
|
// is plainly down never gets this far -- the maintenance gate in Program.cs
|
||||||
|
// answers with maintenance.html first. These catches are the second line: the
|
||||||
|
// gate's health result is cached for a few seconds, so PocketBase can fail between
|
||||||
|
// that check and these reads. Without them that would be a 500; with them the
|
||||||
|
// start page stays a 200 rather than a 503 -- degraded, not broken, and rare.
|
||||||
private async Task LoadPagesAsync(CancellationToken token)
|
private async Task LoadPagesAsync(CancellationToken token)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
|
|||||||
@@ -90,7 +90,27 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.hero .btn:focus-visible {
|
.hero .btn:focus-visible {
|
||||||
outline-color: var(--color-on-header);
|
outline-color: var(--color-focus-inverse);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* On a phone the buttons used to wrap onto a row each: two wide blocks stacked,
|
||||||
|
far too heavy for the top of the page. Instead they share one row in equal
|
||||||
|
columns -- the same buttons as on the desktop, just side by side and a little
|
||||||
|
tighter. Short labels ("Termine", "Kontakt") fit on one line; a longer one
|
||||||
|
wraps inside its button (centred) rather than pushing the other off the row. */
|
||||||
|
@media (max-width: 40rem) {
|
||||||
|
.hero-actions {
|
||||||
|
display: grid;
|
||||||
|
grid-auto-flow: column;
|
||||||
|
grid-auto-columns: 1fr;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero .btn {
|
||||||
|
justify-content: center;
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --- Next event ---------------------------------------------------------------
|
/* --- Next event ---------------------------------------------------------------
|
||||||
@@ -116,7 +136,7 @@
|
|||||||
/* The ring sits outside the card, on the gradient, so it is white like the
|
/* The ring sits outside the card, on the gradient, so it is white like the
|
||||||
hero's button rings. */
|
hero's button rings. */
|
||||||
.next-event:focus-visible {
|
.next-event:focus-visible {
|
||||||
outline: 3px solid var(--color-on-header);
|
outline-color: var(--color-focus-inverse);
|
||||||
outline-offset: 3px;
|
outline-offset: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -208,11 +228,6 @@
|
|||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tile-link:focus-visible {
|
|
||||||
outline: 3px solid var(--color-focus);
|
|
||||||
outline-offset: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* The icon in a soft round tint. */
|
/* The icon in a soft round tint. */
|
||||||
.tile-icon {
|
.tile-icon {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
/* A content page is one reading column: its text, boxes and card grids all stop at
|
||||||
|
42rem (app.css). Left-aligned in the 72rem layout that column left a wide empty
|
||||||
|
strip on the right of a desktop screen; centred, the empty space splits evenly and
|
||||||
|
the page sits balanced under the header. Title and body share the column, so they
|
||||||
|
keep one left edge. The children inside keep margin 0 and simply fill it. */
|
||||||
|
h1,
|
||||||
|
article,
|
||||||
|
p {
|
||||||
|
max-width: 42rem;
|
||||||
|
margin-inline: auto;
|
||||||
|
}
|
||||||
@@ -23,11 +23,6 @@
|
|||||||
color: var(--color-text);
|
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
|
/* Icon and date on one line. The icon belongs to Icon's own scope, so it is
|
||||||
reached through ::deep. */
|
reached through ::deep. */
|
||||||
.post-date {
|
.post-date {
|
||||||
|
|||||||
@@ -8,16 +8,39 @@
|
|||||||
else if (_post is not null)
|
else if (_post is not null)
|
||||||
{
|
{
|
||||||
<PageTitle>@_post.Title</PageTitle>
|
<PageTitle>@_post.Title</PageTitle>
|
||||||
<nav>
|
@* A plain link, not a <nav>: one link is no navigation block, and an unlabelled
|
||||||
<a href="/posts">← Alle Beiträge</a>
|
second <nav> next to the main menu would only add noise for screen readers. *@
|
||||||
</nav>
|
<a class="btn btn-quiet" href="/posts"><Icon Name="arrow-left" Size="18" />Alle Beiträge</a>
|
||||||
<article>
|
|
||||||
<div class="post-content">
|
<article class="post">
|
||||||
<PostDate Value="_post.Date" />
|
<header class="post-header">
|
||||||
<h2>@_post.Title</h2>
|
<p class="post-meta"><Icon Name="calendar" Size="16" /><PostDate Value="_post.Date" /></p>
|
||||||
<div class="markdown-body">@Body</div>
|
<h1>@_post.Title</h1>
|
||||||
</div>
|
</header>
|
||||||
|
<div class="markdown-body post-body">@Body</div>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
|
@if (_neighbors.Previous is not null || _neighbors.Next is not null)
|
||||||
|
{
|
||||||
|
@* Older post on the left, newer on the right, like a timeline. A missing side
|
||||||
|
leaves its cell empty, so the remaining link keeps its place. *@
|
||||||
|
<nav class="post-pager" aria-label="Weitere Beiträge">
|
||||||
|
@if (_neighbors.Previous is { } previous)
|
||||||
|
{
|
||||||
|
<a class="post-pager-link post-pager-previous" href="/posts/@previous.Slug" rel="prev">
|
||||||
|
<span class="post-pager-label"><Icon Name="arrow-left" Size="16" />Vorheriger Beitrag</span>
|
||||||
|
<span class="post-pager-title">@previous.Title</span>
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
@if (_neighbors.Next is { } next)
|
||||||
|
{
|
||||||
|
<a class="post-pager-link post-pager-next" href="/posts/@next.Slug" rel="next">
|
||||||
|
<span class="post-pager-label">Nächster Beitrag<Icon Name="arrow-right" Size="16" /></span>
|
||||||
|
<span class="post-pager-title">@next.Title</span>
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
</nav>
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@* No post and not unavailable: the slug was unknown; NavigationManager.NotFound()
|
@* No post and not unavailable: the slug was unknown; NavigationManager.NotFound()
|
||||||
already ran in OnParametersSetAsync and renders the not-found page. *@
|
already ran in OnParametersSetAsync and renders the not-found page. *@
|
||||||
@@ -32,6 +32,10 @@ public partial class PostDetail
|
|||||||
|
|
||||||
private Post? _post;
|
private Post? _post;
|
||||||
|
|
||||||
|
// The older and newer post for the "Vorheriger / Nächster Beitrag" links at the
|
||||||
|
// end; both empty until loaded or when the post stands alone.
|
||||||
|
private PostNeighbors _neighbors = new(null, null);
|
||||||
|
|
||||||
// Set when the content source could not be reached or read. The markup then
|
// Set when the content source could not be reached or read. The markup then
|
||||||
// shows a "temporarily unavailable" note instead of a not-found page, matching
|
// shows a "temporarily unavailable" note instead of a not-found page, matching
|
||||||
// the 503 this sets on the response.
|
// the 503 this sets on the response.
|
||||||
@@ -48,6 +52,7 @@ public partial class PostDetail
|
|||||||
var posts = await PocketBase.GetPostsAsync(HttpContext?.RequestAborted ?? CancellationToken.None);
|
var posts = await PocketBase.GetPostsAsync(HttpContext?.RequestAborted ?? CancellationToken.None);
|
||||||
_post = posts.FirstOrDefault(
|
_post = posts.FirstOrDefault(
|
||||||
post => string.Equals(post.Slug, Slug, StringComparison.OrdinalIgnoreCase));
|
post => string.Equals(post.Slug, Slug, StringComparison.OrdinalIgnoreCase));
|
||||||
|
_neighbors = PostNeighbors.Of(posts, Slug);
|
||||||
}
|
}
|
||||||
catch (PocketBaseUnavailableException exception)
|
catch (PocketBaseUnavailableException exception)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,15 +1,116 @@
|
|||||||
/* Push the whole article body down from the "all posts" link above it. Wraps the
|
/* The article: the site's centred 42rem reading column (as on a content page,
|
||||||
date, heading and body, so scoped CSS applies (the date sits in the PostDate
|
ContentPage.razor.css) with generous leading, so a long post reads like a page,
|
||||||
child component and would not be reachable on its own). The date's own look
|
not like a form. Everything inside follows this width -- the global 42rem cap on
|
||||||
comes from PostDate.razor.css. */
|
paragraphs, lists and boxes is lifted here, so nothing stops short of the column
|
||||||
.post-content {
|
(the post's own leading makes the text a touch wider than elsewhere). */
|
||||||
margin-top: 1.5rem;
|
.post {
|
||||||
|
max-width: 42rem;
|
||||||
|
margin: var(--space-4) auto 0;
|
||||||
|
line-height: 1.75;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The post title is the first h2 in the content, sitting right under its date, so
|
/* The back link is an inline button, which margin: auto cannot centre. A left
|
||||||
drop the large default heading gap above it (h1..h4 carry --space-5 top margin
|
margin of half the free space puts it on the column's left edge instead, and
|
||||||
globally); date and title belong together. Scoped to :first-of-type so h2
|
max() keeps it at 0 once the screen is narrower than the column. */
|
||||||
subheadings inside the Markdown body keep their normal spacing. */
|
.btn-quiet {
|
||||||
.post-content h2:first-of-type {
|
margin-left: max(0px, (100% - 42rem) / 2);
|
||||||
margin-top: var(--space-1);
|
}
|
||||||
|
|
||||||
|
.post ::deep :is(p, ul, ol, blockquote, .aufruf, .hinweis) {
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Date above the title as a quiet meta line, with the same calendar icon as on the
|
||||||
|
post cards. The title follows directly, so the gap between them stays small
|
||||||
|
(h1 carries no top margin globally). */
|
||||||
|
.post-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-1);
|
||||||
|
margin: 0 0 var(--space-2);
|
||||||
|
color: var(--color-text-soft);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-meta ::deep .icon {
|
||||||
|
color: var(--color-accent-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The first paragraph as the lead: larger and in the soft tone, so it reads as an
|
||||||
|
introduction. Pure CSS on the editor's Markdown -- if a post starts with a
|
||||||
|
heading or a list instead, there simply is no lead. Same text colour token as
|
||||||
|
the rest, so it keeps AA contrast in both schemes. */
|
||||||
|
.post-body ::deep > p:first-child {
|
||||||
|
color: var(--color-text-soft);
|
||||||
|
font-size: 1.2rem;
|
||||||
|
line-height: 1.65;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Older/newer post at the end: two cards side by side, older on the left, newer on
|
||||||
|
the right. The grid keeps each in its column even if the other is missing. Hover
|
||||||
|
= border and shadow, nothing moves. */
|
||||||
|
.post-pager {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: var(--space-3);
|
||||||
|
max-width: 42rem;
|
||||||
|
margin: var(--space-6) auto 0;
|
||||||
|
padding-top: var(--space-4);
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-pager-link {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-1);
|
||||||
|
padding: var(--space-3) var(--space-4);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--color-bg);
|
||||||
|
color: var(--color-text);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: box-shadow var(--transition), border-color var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-pager-link:hover {
|
||||||
|
border-color: var(--color-brand);
|
||||||
|
box-shadow: var(--shadow-hover);
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-pager-next {
|
||||||
|
grid-column: 2;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-pager-label {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-1);
|
||||||
|
color: var(--color-text-soft);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-pager-next .post-pager-label {
|
||||||
|
align-self: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-pager-title {
|
||||||
|
color: var(--color-brand);
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* On a phone the two cards stack: a half-width card would squeeze a long title
|
||||||
|
into a narrow column. Older first, as in the wide layout. */
|
||||||
|
@media (max-width: 40rem) {
|
||||||
|
.post-pager {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-pager-next {
|
||||||
|
grid-column: 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -10,3 +10,20 @@
|
|||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Same centred 42rem reading column as a content page (ContentPage.razor.css), so
|
||||||
|
every page of the site shares one column under the header. Two cards still fit
|
||||||
|
side by side in it. */
|
||||||
|
h1,
|
||||||
|
article,
|
||||||
|
p,
|
||||||
|
.post-list {
|
||||||
|
max-width: 42rem;
|
||||||
|
margin-inline: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The intro's last paragraph drops its margin (app.css), so the first card would
|
||||||
|
sit right under the text; this gives it the same air as between sections. */
|
||||||
|
article {
|
||||||
|
margin-bottom: var(--space-5);
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
using Elternbeirat.Contracts;
|
||||||
|
|
||||||
|
namespace Elternbeirat.Web.Features.Posts;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The posts next to a given post in time: the one published before it and the
|
||||||
|
/// one published after it.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="Previous">
|
||||||
|
/// The older post, or <see langword="null"/> if the post is the oldest.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="Next">
|
||||||
|
/// The newer post, or <see langword="null"/> if the post is the newest.
|
||||||
|
/// </param>
|
||||||
|
/// <remarks>
|
||||||
|
/// "Previous" and "next" follow time, as a reader moving through the news would:
|
||||||
|
/// "Vorheriger Beitrag" is the older one, "Nächster Beitrag" the newer one. The
|
||||||
|
/// list order of the posts page (newest first) is the other way round, which is
|
||||||
|
/// why the direction is pinned down here instead of being left to the markup.
|
||||||
|
/// </remarks>
|
||||||
|
/// <seealso cref="PostDetail"/>
|
||||||
|
public sealed record PostNeighbors(Post? Previous, Post? Next)
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Finds the neighbours of the post with the given slug.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="posts">
|
||||||
|
/// All public posts, newest first, as <c>PocketBaseClient.GetPostsAsync</c>
|
||||||
|
/// returns them.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="slug">The slug of the post being shown.</param>
|
||||||
|
/// <returns>
|
||||||
|
/// The older and the newer post. Both are <see langword="null"/> if the slug is
|
||||||
|
/// not in the list, or if it is the only post.
|
||||||
|
/// </returns>
|
||||||
|
/// <remarks>
|
||||||
|
/// The slug is compared case-insensitively, like the route lookup in
|
||||||
|
/// <see cref="PostDetail"/>, so a page reached as <c>/posts/New-Board</c> still
|
||||||
|
/// gets its neighbours.
|
||||||
|
/// </remarks>
|
||||||
|
/// <example>
|
||||||
|
/// <code>
|
||||||
|
/// // posts = [March, February, January] (newest first)
|
||||||
|
/// var neighbors = PostNeighbors.Of(posts, "february");
|
||||||
|
/// // neighbors.Previous is January, neighbors.Next is March
|
||||||
|
/// </code>
|
||||||
|
/// </example>
|
||||||
|
public static PostNeighbors Of(IReadOnlyList<Post> posts, string slug) =>
|
||||||
|
IndexOf(posts, slug) is var index and >= 0
|
||||||
|
? new PostNeighbors(
|
||||||
|
Previous: index + 1 < posts.Count ? posts[index + 1] : null,
|
||||||
|
Next: index > 0 ? posts[index - 1] : null)
|
||||||
|
: new PostNeighbors(null, null);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The position of the post with <paramref name="slug"/>, or <c>-1</c>.
|
||||||
|
/// </summary>
|
||||||
|
private static int IndexOf(IReadOnlyList<Post> posts, string slug) =>
|
||||||
|
posts
|
||||||
|
.Select((post, index) => (post, index))
|
||||||
|
.FirstOrDefault(
|
||||||
|
entry => string.Equals(entry.post.Slug, slug, StringComparison.OrdinalIgnoreCase),
|
||||||
|
(null!, -1))
|
||||||
|
.index;
|
||||||
|
}
|
||||||
@@ -2,6 +2,8 @@ using Elternbeirat.Contracts;
|
|||||||
using Elternbeirat.PocketBase;
|
using Elternbeirat.PocketBase;
|
||||||
using Elternbeirat.Web.Components;
|
using Elternbeirat.Web.Components;
|
||||||
using Elternbeirat.Web.Features.Events;
|
using Elternbeirat.Web.Features.Events;
|
||||||
|
using Elternbeirat.Web.Shared;
|
||||||
|
using Microsoft.AspNetCore.Components.Endpoints;
|
||||||
using Microsoft.AspNetCore.HttpOverrides;
|
using Microsoft.AspNetCore.HttpOverrides;
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
@@ -25,6 +27,10 @@ builder.Services.AddHttpClient<PocketBaseClient>(client =>
|
|||||||
client.Timeout = TimeSpan.FromSeconds(5);
|
client.Timeout = TimeSpan.FromSeconds(5);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Shared by all requests, so the maintenance gate below asks PocketBase at most once
|
||||||
|
// every few seconds rather than on every page view.
|
||||||
|
builder.Services.AddSingleton<PocketBaseHealthCache>();
|
||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
|
||||||
// NPM sits in front of the app and handles HTTPS. Without this line the app would
|
// NPM sits in front of the app and handles HTTPS. Without this line the app would
|
||||||
@@ -48,6 +54,37 @@ if (app.Environment.IsDevelopment() is false)
|
|||||||
// is down (a 503) it would wrongly show "not found" instead of our own message.
|
// is down (a 503) it would wrongly show "not found" instead of our own message.
|
||||||
app.UseAntiforgery();
|
app.UseAntiforgery();
|
||||||
|
|
||||||
|
// Maintenance gate: while PocketBase is down, every page gets the static
|
||||||
|
// maintenance.html with 503 instead of a half-empty page (the menu comes from
|
||||||
|
// PocketBase too). Routing has already picked the endpoint at this point, so the gate
|
||||||
|
// covers exactly the Razor component pages: /health, the calendar feeds, the static
|
||||||
|
// assets (incl. maintenance.html and its font) are not components and answer for
|
||||||
|
// themselves. A new endpoint is thus left alone unless it is a page. The health
|
||||||
|
// result is cached (PocketBaseHealthCache), so the page may come back a few seconds
|
||||||
|
// after PocketBase does. Retry-After tells well-behaved clients and crawlers when to
|
||||||
|
// come back; no-store keeps the 503 page out of any cache.
|
||||||
|
app.Use(async (context, next) =>
|
||||||
|
{
|
||||||
|
var isPage = context.GetEndpoint()?.Metadata.GetMetadata<ComponentTypeMetadata>() is not null;
|
||||||
|
var services = context.RequestServices;
|
||||||
|
if (!isPage
|
||||||
|
|| await services.GetRequiredService<PocketBaseHealthCache>().IsHealthyAsync(
|
||||||
|
services.GetRequiredService<PocketBaseClient>().IsHealthyAsync,
|
||||||
|
context.RequestAborted))
|
||||||
|
{
|
||||||
|
await next(context);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
context.Response.StatusCode = StatusCodes.Status503ServiceUnavailable;
|
||||||
|
context.Response.Headers.RetryAfter = "60";
|
||||||
|
context.Response.Headers.CacheControl = "no-store";
|
||||||
|
context.Response.ContentType = "text/html; charset=utf-8";
|
||||||
|
await context.Response.SendFileAsync(
|
||||||
|
app.Environment.WebRootFileProvider.GetFileInfo("maintenance.html"),
|
||||||
|
context.RequestAborted);
|
||||||
|
});
|
||||||
|
|
||||||
app.MapStaticAssets();
|
app.MapStaticAssets();
|
||||||
app.MapRazorComponents<App>();
|
app.MapRazorComponents<App>();
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ namespace Elternbeirat.Web.Shared;
|
|||||||
/// <para>
|
/// <para>
|
||||||
/// Source: Lucide (<c>lucide.dev</c>), ISC license; the icons derived from
|
/// Source: Lucide (<c>lucide.dev</c>), ISC license; the icons derived from
|
||||||
/// Feather (<c>calendar</c>, <c>clock</c>, <c>download</c>,
|
/// Feather (<c>calendar</c>, <c>clock</c>, <c>download</c>,
|
||||||
/// <c>external-link</c>, <c>help-circle</c>, <c>info</c>, <c>arrow-right</c>)
|
/// <c>external-link</c>, <c>help-circle</c>, <c>info</c>, <c>arrow-right</c>,
|
||||||
|
/// <c>arrow-left</c>)
|
||||||
/// are MIT, Copyright (c) 2013-present Cole Bemis. Both licenses only ask that
|
/// are MIT, Copyright (c) 2013-present Cole Bemis. Both licenses only ask that
|
||||||
/// the notice travels with the copy, which this comment does. Only the inner
|
/// the notice travels with the copy, which this comment does. Only the inner
|
||||||
/// elements are stored; <see cref="Icon"/> supplies the shared
|
/// elements are stored; <see cref="Icon"/> supplies the shared
|
||||||
@@ -55,6 +56,8 @@ public static class IconSet
|
|||||||
"""<circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><path d="M12 17h.01"/>""",
|
"""<circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><path d="M12 17h.01"/>""",
|
||||||
["newspaper"] =
|
["newspaper"] =
|
||||||
"""<path d="M15 18h-5"/><path d="M18 14h-8"/><path d="M4 22h16a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2H8a2 2 0 0 0-2 2v16a2 2 0 0 1-4 0v-9a2 2 0 0 1 2-2h2"/><rect width="8" height="4" x="10" y="6" rx="1"/>""",
|
"""<path d="M15 18h-5"/><path d="M18 14h-8"/><path d="M4 22h16a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2H8a2 2 0 0 0-2 2v16a2 2 0 0 1-4 0v-9a2 2 0 0 1 2-2h2"/><rect width="8" height="4" x="10" y="6" rx="1"/>""",
|
||||||
|
["arrow-left"] =
|
||||||
|
"""<path d="m12 19-7-7 7-7"/><path d="M19 12H5"/>""",
|
||||||
["arrow-right"] =
|
["arrow-right"] =
|
||||||
"""<path d="M5 12h14"/><path d="m12 5 7 7-7 7"/>""",
|
"""<path d="M5 12h14"/><path d="m12 5 7 7-7 7"/>""",
|
||||||
["external-link"] =
|
["external-link"] =
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
namespace Elternbeirat.Web.Shared;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Remembers for a few seconds whether PocketBase answered its health check, so the
|
||||||
|
/// maintenance gate does not probe PocketBase on every page request.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="time">
|
||||||
|
/// The clock that decides when a result is stale; injected so tests can step past
|
||||||
|
/// <see cref="Lifetime"/> without waiting.
|
||||||
|
/// </param>
|
||||||
|
/// <remarks>
|
||||||
|
/// The gate in <c>Program.cs</c> asks this cache before rendering any page and serves
|
||||||
|
/// the static <c>maintenance.html</c> with 503 while the answer is "unhealthy". A
|
||||||
|
/// failed check is kept just as long as a passed one: while PocketBase is down every
|
||||||
|
/// probe would otherwise wait for the connection to fail again.
|
||||||
|
/// <para>
|
||||||
|
/// The price of caching is a short blind spot in both directions: after
|
||||||
|
/// PocketBase fails, pages may still render for up to <see cref="Lifetime"/> (the
|
||||||
|
/// components' own fallbacks cover that), and after it recovers the maintenance
|
||||||
|
/// page may stay up for as long.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Registered as a singleton. No lock: two requests that find the result stale at
|
||||||
|
/// the same moment both probe, and the later answer wins -- harmless, and cheaper
|
||||||
|
/// than making every request wait on one another.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
/// <example>
|
||||||
|
/// <code>
|
||||||
|
/// var healthy = await cache.IsHealthyAsync(pocketBase.IsHealthyAsync, context.RequestAborted);
|
||||||
|
/// </code>
|
||||||
|
/// </example>
|
||||||
|
/// <seealso cref="PocketBase.PocketBaseClient.IsHealthyAsync"/>
|
||||||
|
public sealed class PocketBaseHealthCache(TimeProvider time)
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// How long a health check result is reused before PocketBase is asked again.
|
||||||
|
/// </summary>
|
||||||
|
public static readonly TimeSpan Lifetime = TimeSpan.FromSeconds(10);
|
||||||
|
|
||||||
|
// One reference, swapped as a whole, so a reader never sees the result of one
|
||||||
|
// check paired with the time of another.
|
||||||
|
private volatile Check? _last;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the last known health of PocketBase, probing again once it is older
|
||||||
|
/// than <see cref="Lifetime"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="probe">
|
||||||
|
/// The actual check, usually <see cref="PocketBase.PocketBaseClient.IsHealthyAsync"/>.
|
||||||
|
/// Passed in rather than injected because the typed client is transient and this
|
||||||
|
/// cache is a singleton.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="token">Cancels the probe, e.g. when the visitor goes away.</param>
|
||||||
|
/// <returns>
|
||||||
|
/// <see langword="true"/> if PocketBase answered its last check (or answers this
|
||||||
|
/// one), otherwise <see langword="false"/>.
|
||||||
|
/// </returns>
|
||||||
|
/// <exception cref="OperationCanceledException">
|
||||||
|
/// <paramref name="token"/> was cancelled during a probe; nothing is cached then.
|
||||||
|
/// </exception>
|
||||||
|
public async Task<bool> IsHealthyAsync(Func<CancellationToken, Task<bool>> probe, CancellationToken token) =>
|
||||||
|
_last is { } last && time.GetUtcNow() - last.At < Lifetime
|
||||||
|
? last.Healthy
|
||||||
|
: (_last = new Check(await probe(token), time.GetUtcNow())).Healthy;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One health check result and when it was taken.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="Healthy">Whether PocketBase answered.</param>
|
||||||
|
/// <param name="At">When the answer came in.</param>
|
||||||
|
private sealed record Check(bool Healthy, DateTimeOffset At);
|
||||||
|
}
|
||||||
@@ -11,20 +11,22 @@
|
|||||||
--color-brand-dark: #1f5477;
|
--color-brand-dark: #1f5477;
|
||||||
--color-brand-tint: #eaf3f9;
|
--color-brand-tint: #eaf3f9;
|
||||||
|
|
||||||
/* The header bar's background: the IGMH blue at full strength. Its own token
|
/* The header bar's background: the IGMH blue, eased one step deeper. The
|
||||||
(not --color-brand) both because it only ever carries white text (so it needs
|
original #328fc6 carries white menu text at only 3.6:1, below AA for normal
|
||||||
no darkening) and because it must stay a deep blue in dark mode, where
|
text; #2a77a6 is the same hue at 4.9:1. Its own token (not --color-brand)
|
||||||
--color-brand is lightened for text/links. */
|
because it only ever carries white text and must stay a deep blue in dark
|
||||||
--color-header: #328fc6;
|
mode, where --color-brand is lightened for text/links. */
|
||||||
|
--color-header: #2a77a6;
|
||||||
|
|
||||||
/* Text and menu states on the header. White text, and two translucent white
|
/* Text and menu states on the header. White text, and two translucent dark
|
||||||
washes for the menu pills: they lighten whatever part of the gradient sits
|
washes for the menu pills: they deepen whatever part of the gradient sits
|
||||||
behind a link, so one value works from the blue end to the green end. The
|
behind a link, so one value works from the blue end to the green end and the
|
||||||
active pill is the stronger one, so "where am I" outranks "where is the
|
white text gains contrast instead of losing it (a white wash dropped the
|
||||||
mouse". */
|
active pill to 2.7:1). The active pill is the stronger one, so "where am I"
|
||||||
|
outranks "where is the mouse". */
|
||||||
--color-on-header: #fff;
|
--color-on-header: #fff;
|
||||||
--color-nav-hover: rgba(255, 255, 255, 0.12);
|
--color-nav-hover: rgba(0, 0, 0, 0.12);
|
||||||
--color-nav-active: rgba(255, 255, 255, 0.2);
|
--color-nav-active: rgba(0, 0, 0, 0.2);
|
||||||
|
|
||||||
/* Footer: a dark brand blue, so the page visibly ends there. Light text in two
|
/* Footer: a dark brand blue, so the page visibly ends there. Light text in two
|
||||||
steps (headings and the name brighter, links softer); both stay >= 7:1 on the
|
steps (headings and the name brighter, links softer); both stay >= 7:1 on the
|
||||||
@@ -90,6 +92,11 @@
|
|||||||
one; on the gradient the hero switches it to white. */
|
one; on the gradient the hero switches it to white. */
|
||||||
--color-focus: #1f5477;
|
--color-focus: #1f5477;
|
||||||
|
|
||||||
|
/* The focus ring on the dark brand surfaces (header, hero gradient, footer),
|
||||||
|
where the dark ring above would vanish: white in both schemes, >= 4.9:1 on
|
||||||
|
every one of them. */
|
||||||
|
--color-focus-inverse: #fff;
|
||||||
|
|
||||||
/* Headings. Its own token (not --color-brand) so headings and links can differ:
|
/* Headings. Its own token (not --color-brand) so headings and links can differ:
|
||||||
in dark mode headings become a soft white while links keep the brand blue. */
|
in dark mode headings become a soft white while links keep the brand blue. */
|
||||||
--color-heading: #2b6f9c;
|
--color-heading: #2b6f9c;
|
||||||
@@ -147,6 +154,28 @@
|
|||||||
because heavy letters at large sizes otherwise look spaced out. */
|
because heavy letters at large sizes otherwise look spaced out. */
|
||||||
--font-weight-heading: 800;
|
--font-weight-heading: 800;
|
||||||
--letter-spacing-heading: -0.015em;
|
--letter-spacing-heading: -0.015em;
|
||||||
|
|
||||||
|
/* WCAG AA check of the colour pairs, light / dark (computed, October 2026).
|
||||||
|
Text needs 4.5:1, large text and focus rings/graphics 3:1.
|
||||||
|
text on card / page ............... 15.9 / 6.2 14.6 / 7.2
|
||||||
|
soft text on card / page .......... 7.5 / 6.2 6.9 / 7.2
|
||||||
|
soft text on green / blue tint .... 6.8 / 5.6 6.6 / 5.7
|
||||||
|
link (brand) on card / page ....... 5.4 / 7.5 5.0 / 8.7
|
||||||
|
link on green / blue tint ......... 5.0 / 6.7 4.8 / 6.9
|
||||||
|
heading on card / page ............ 5.4 / 9.3 5.0 / 10.9
|
||||||
|
white on header / gradient green .. 4.9 / 5.5 5.2 / 6.4
|
||||||
|
white on menu pill hover / active . 6.0 / 6.6 6.9 / 7.6
|
||||||
|
footer heading / link on footer ... 10.4 / 10.5 7.4 / 6.9
|
||||||
|
white on button / button hover .... 5.4 / 5.5 8.1 / 8.1
|
||||||
|
light button text ................. 8.1 / 9.7 (hover 7.2 / 8.3)
|
||||||
|
white on calendar band ............ 5.2 / 6.4
|
||||||
|
avatar blue / green ............... 7.2 / 8.6 5.9 / 6.7
|
||||||
|
key figure / caption on card ...... 5.4 / 7.5 5.0 / 6.9
|
||||||
|
focus ring on page / card ......... 7.5 / 11.0 8.1 / 9.4
|
||||||
|
white ring on header / footer ..... 4.9 / 5.5 11.4 / 16.6
|
||||||
|
green markers on card (graphic) ... 3.8 / 7.5
|
||||||
|
All pass. Exempt as pure decoration: the light green --color-accent (card
|
||||||
|
stripes, heading bars, the active-menu stroke), which never carries text. */
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Dark mode: follows the visitor's OS setting automatically (prefers-color-scheme).
|
/* Dark mode: follows the visitor's OS setting automatically (prefers-color-scheme).
|
||||||
@@ -164,7 +193,8 @@
|
|||||||
--color-brand-dark: #a3d5f4; /* hover goes lighter, not darker, on dark */
|
--color-brand-dark: #a3d5f4; /* hover goes lighter, not darker, on dark */
|
||||||
--color-brand-tint: #223038;
|
--color-brand-tint: #223038;
|
||||||
|
|
||||||
--color-header: #266f9c; /* the IGMH blue eased down, to sit calmly on dark */
|
--color-header: #266f9c; /* the IGMH blue eased down, to sit calmly on
|
||||||
|
dark; white on it 5.5:1 */
|
||||||
|
|
||||||
--color-footer: #10202e; /* a deep night blue: darker than the page, so
|
--color-footer: #10202e; /* a deep night blue: darker than the page, so
|
||||||
the band still reads as the page's end */
|
the band still reads as the page's end */
|
||||||
@@ -310,6 +340,60 @@ h1:focus {
|
|||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Keyboard focus, one look for the whole site: a thick ring in --color-focus with a
|
||||||
|
little air between ring and element, so it never melts into a card's border or a
|
||||||
|
button's fill. :focus-visible, not :focus -- the ring shows for keyboard users
|
||||||
|
and stays away after a mouse click. Covers every focusable thing a page has:
|
||||||
|
links (and with them every card, which is one big link), buttons, the <summary>
|
||||||
|
of each disclosure, form fields and anything made focusable with tabindex. Dark
|
||||||
|
brand surfaces (header, hero, footer) switch the colour to
|
||||||
|
--color-focus-inverse.
|
||||||
|
tabindex="-1" is left out: such an element is never reached with Tab, only
|
||||||
|
focused by code -- the h1 by FocusOnNavigate on every page load, <main> by the
|
||||||
|
skip link. Chromium counts that as :focus-visible, and since this rule outweighs
|
||||||
|
h1:focus / main:focus below, the page title would otherwise get a ring. */
|
||||||
|
:is(a, button, summary, input, select, textarea, [tabindex]:not([tabindex="-1"])):focus-visible {
|
||||||
|
outline: 3px solid var(--color-focus);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* <main> takes focus from the skip link (tabindex="-1") only so the next Tab
|
||||||
|
continues inside the content; a ring around the whole page would just be noise. */
|
||||||
|
main:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Skip link: the first thing on every page, so keyboard and screen-reader users
|
||||||
|
can jump past the header menu straight to the content. Off screen until it gets
|
||||||
|
focus, then it drops in at the top left over the sticky header -- a link that
|
||||||
|
is only hidden visually stays in the tab order, display:none would remove it. */
|
||||||
|
.skip-link {
|
||||||
|
position: absolute;
|
||||||
|
top: var(--space-2);
|
||||||
|
left: var(--space-2);
|
||||||
|
z-index: 100;
|
||||||
|
padding: var(--space-2) var(--space-4);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--color-button-light);
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
color: var(--color-on-button-light);
|
||||||
|
font-weight: 700;
|
||||||
|
text-decoration: none;
|
||||||
|
transform: translateY(-200%);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* It lands on the header, so it takes the light button look and the white ring
|
||||||
|
of the dark brand surfaces. Shown instantly, no transition: it is a jump aid,
|
||||||
|
not an effect. */
|
||||||
|
.skip-link:focus {
|
||||||
|
transform: none;
|
||||||
|
outline-color: var(--color-focus-inverse);
|
||||||
|
}
|
||||||
|
|
||||||
|
.skip-link:hover {
|
||||||
|
color: var(--color-on-button-light);
|
||||||
|
}
|
||||||
|
|
||||||
/* Text for screen readers only: off screen visually, still read aloud. For places
|
/* 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
|
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
|
must carry the full information. Not display:none -- that hides it from screen
|
||||||
@@ -353,11 +437,6 @@ h1:focus {
|
|||||||
color var(--transition), border-color var(--transition);
|
color var(--transition), border-color var(--transition);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn:focus-visible {
|
|
||||||
outline: 3px solid var(--color-focus);
|
|
||||||
outline-offset: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary {
|
.btn-primary {
|
||||||
background: var(--color-button);
|
background: var(--color-button);
|
||||||
border-color: var(--color-button);
|
border-color: var(--color-button);
|
||||||
@@ -382,6 +461,21 @@ h1:focus {
|
|||||||
color: var(--color-brand);
|
color: var(--color-brand);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Quiet = no frame at all, just the brand-blue label; hover lays the blue tint
|
||||||
|
behind it. For a way back ("Alle Beiträge") that should be easy to find but not
|
||||||
|
compete with the page's content. The negative inline margin lines the label up
|
||||||
|
with the text below while the hover tint still gets its padding. */
|
||||||
|
.btn-quiet {
|
||||||
|
margin-inline: calc(-1 * var(--space-3));
|
||||||
|
padding-inline: var(--space-3);
|
||||||
|
color: var(--color-brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-quiet:hover {
|
||||||
|
background: var(--color-brand-tint);
|
||||||
|
color: var(--color-brand);
|
||||||
|
}
|
||||||
|
|
||||||
/* Header navigation links. These live here (global), not in MainLayout.razor.css:
|
/* Header navigation links. These live here (global), not in MainLayout.razor.css:
|
||||||
NavLink renders plain <a> elements without a scoped-CSS attribute, so a scoped
|
NavLink renders plain <a> elements without a scoped-CSS attribute, so a scoped
|
||||||
".main-nav a" rule would not match them -- the links would fall back to the
|
".main-nav a" rule would not match them -- the links would fall back to the
|
||||||
@@ -403,6 +497,12 @@ h1:focus {
|
|||||||
background: var(--color-nav-hover);
|
background: var(--color-nav-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The dark default ring would vanish on the blue header (2.3:1); white stands out
|
||||||
|
on the whole gradient. */
|
||||||
|
.main-nav a:focus-visible {
|
||||||
|
outline-color: var(--color-focus-inverse);
|
||||||
|
}
|
||||||
|
|
||||||
/* NavLink adds .active to the current route's link: a stronger pill plus a short
|
/* NavLink adds .active to the current route's link: a stronger pill plus a short
|
||||||
green stroke under the text mark where the visitor is. The stroke is drawn by
|
green stroke under the text mark where the visitor is. The stroke is drawn by
|
||||||
::after, not a border, so it follows the text width inside the rounded pill. */
|
::after, not a border, so it follows the text width inside the rounded pill. */
|
||||||
@@ -421,19 +521,18 @@ h1:focus {
|
|||||||
background: var(--color-accent);
|
background: var(--color-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Mobile: the stacked links in the open menu become full-width rows with a
|
/* Mobile: the open menu stacks the links centred, each as wide as its text -- so
|
||||||
rounded highlight instead of pills, and the green stroke shrinks to a short
|
they stay the same pills as on the desktop, with the stroke under the whole word.
|
||||||
mark at the start, so a long row does not get a long line. Same global
|
Only taller, for a comfortable tap target; the stroke moves up with the extra
|
||||||
placement, same NavLink reason as above. */
|
padding so it still sits right under the text, not at the pill's edge. Same
|
||||||
|
global placement, same NavLink reason as above. */
|
||||||
@media (max-width: 40rem) {
|
@media (max-width: 40rem) {
|
||||||
.main-nav a {
|
.main-nav a {
|
||||||
padding: 0.6rem var(--space-3);
|
padding: 0.6rem var(--space-3);
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.main-nav a.active::after {
|
.main-nav a.active::after {
|
||||||
right: auto;
|
bottom: 0.55rem;
|
||||||
width: 1.5rem;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -807,24 +906,39 @@ h1:focus {
|
|||||||
mask: var(--icon-info) center / contain no-repeat;
|
mask: var(--icon-info) center / contain no-repeat;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ::: kacheln and ::: kennzahlen -- a list laid out as a grid of cards. auto-fill
|
/* ::: kacheln and ::: kennzahlen -- a list laid out as a grid of cards. They keep
|
||||||
with a minimum width gives as many columns as fit (one on a phone) without any
|
the 42rem reading column like the text and the other boxes: one calm left and
|
||||||
breakpoint. */
|
right edge down the page instead of blocks of three different widths. */
|
||||||
.markdown-body :is(.kacheln, .kennzahlen) > ul {
|
.markdown-body :is(.kacheln, .kennzahlen) > ul {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: var(--space-3);
|
gap: var(--space-3);
|
||||||
max-width: none;
|
box-sizing: border-box;
|
||||||
|
max-width: 42rem;
|
||||||
margin: 0 0 var(--space-4);
|
margin: 0 0 var(--space-4);
|
||||||
padding: 0;
|
padding: 0;
|
||||||
list-style: none;
|
list-style: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Kacheln: auto-fit with a 15rem minimum gives exactly two columns in the 42rem
|
||||||
|
(three would need 46rem), so four tiles sit as 2 x 2. Below ~31rem it drops to
|
||||||
|
one column -- two would squeeze a long word like "Chancengerechtigkeit" out of
|
||||||
|
its card. min() keeps a single card from overflowing a very narrow screen. */
|
||||||
.markdown-body .kacheln > ul {
|
.markdown-body .kacheln > ul {
|
||||||
grid-template-columns: repeat(auto-fill, minmax(14rem, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(min(100%, 15rem), 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Kennzahlen: one row of equal columns where there is room. auto-fill used to
|
||||||
|
leave empty tracks on the right on a wide screen and one figure alone in a second
|
||||||
|
row on a phone. A narrow column switches to stacked rows instead (below). The
|
||||||
|
block is its own container, so the switch follows the width the figures actually
|
||||||
|
get, not the screen. */
|
||||||
|
.markdown-body .kennzahlen {
|
||||||
|
container-type: inline-size;
|
||||||
}
|
}
|
||||||
|
|
||||||
.markdown-body .kennzahlen > ul {
|
.markdown-body .kennzahlen > ul {
|
||||||
grid-template-columns: repeat(auto-fill, minmax(10rem, 1fr));
|
grid-auto-flow: column;
|
||||||
|
grid-auto-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The grid gap does the spacing; the list's own item spacing would only push the
|
/* The grid gap does the spacing; the list's own item spacing would only push the
|
||||||
@@ -848,24 +962,87 @@ h1:focus {
|
|||||||
color: var(--color-heading);
|
color: var(--color-heading);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* On a phone the tiles keep their card but tighten up, so four of them stacked
|
||||||
|
do not turn into a long scroll. */
|
||||||
|
@media (max-width: 40rem) {
|
||||||
|
.markdown-body .kacheln > ul {
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-body .kacheln li {
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-body .kacheln li > strong:first-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* Kennzahlen: "- **seit 1975** Gründung" -- the bold part becomes a big figure,
|
/* Kennzahlen: "- **seit 1975** Gründung" -- the bold part becomes a big figure,
|
||||||
the rest a small caption beneath. Brand blue for the figure, not the green: the
|
the rest a small caption beneath. A white card with a green stroke on top, not
|
||||||
green does not reach 3:1 on its own tint, the blue does. */
|
a green wash: a ::: hinweis often follows right below, and two green tints in a
|
||||||
|
row ran together into one green patch. Brand blue for the figure: 5.4:1 on the
|
||||||
|
card (5.0 dark), where the green would not reach 3:1. */
|
||||||
.markdown-body .kennzahlen li {
|
.markdown-body .kennzahlen li {
|
||||||
padding: var(--space-4) var(--space-3);
|
padding: var(--space-4) var(--space-2);
|
||||||
|
border-top: 4px solid var(--color-accent);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
background: var(--color-accent-tint);
|
background: var(--color-bg);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
color: var(--color-text-soft);
|
color: var(--color-text-soft);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The figure scales with the block (cqi, not vw): 2rem in the full 42rem, a little
|
||||||
|
less just above the switch to rows. nowrap: "seit 1975" split over two lines
|
||||||
|
reads as two figures. */
|
||||||
.markdown-body .kennzahlen li > strong:first-child {
|
.markdown-body .kennzahlen li > strong:first-child {
|
||||||
display: block;
|
display: block;
|
||||||
color: var(--color-brand);
|
color: var(--color-brand);
|
||||||
font-size: 2rem;
|
font-size: clamp(1.5rem, 4.75cqi, 2rem);
|
||||||
font-weight: var(--font-weight-heading);
|
font-weight: var(--font-weight-heading);
|
||||||
letter-spacing: var(--letter-spacing-heading);
|
letter-spacing: var(--letter-spacing-heading);
|
||||||
line-height: 1.15;
|
line-height: 1.15;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Below 30rem three columns get under 10rem each: the figure no longer fits on one
|
||||||
|
line and a caption like "im Einsatz für die IGMH" stacks up word by word. So the
|
||||||
|
figures become rows of one card -- figure left, caption beside it, a thin line
|
||||||
|
between rows. Subgrid gives every row the same figure column, so the captions
|
||||||
|
line up whatever the figure's length. */
|
||||||
|
@container (max-width: 30rem) {
|
||||||
|
.markdown-body .kennzahlen > ul {
|
||||||
|
grid-auto-flow: row;
|
||||||
|
grid-template-columns: max-content 1fr;
|
||||||
|
gap: 0;
|
||||||
|
border-top: 4px solid var(--color-accent);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--color-bg);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-body .kennzahlen li {
|
||||||
|
display: grid;
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
grid-template-columns: subgrid;
|
||||||
|
column-gap: var(--space-3);
|
||||||
|
align-items: baseline;
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
border-top: none;
|
||||||
|
border-radius: 0;
|
||||||
|
background: none;
|
||||||
|
box-shadow: none;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-body .kennzahlen li + li {
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-body .kennzahlen li > strong:first-child {
|
||||||
|
font-size: 1.35rem;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ::: team -- one person card per list item (Markdown.cs/TeamMember.cs build the
|
/* ::: team -- one person card per list item (Markdown.cs/TeamMember.cs build the
|
||||||
@@ -873,13 +1050,17 @@ h1:focus {
|
|||||||
a normal heading; every group is a block of its own. One raster for all of them,
|
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
|
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.
|
width as a group of six, not a stretched one. 17rem fits a long double name.
|
||||||
|
Like every other block it stays in the 42rem reading column, so the cards share
|
||||||
|
the edges of the text above and below them; that leaves room for two columns
|
||||||
|
(three would need 52.5rem), one on a phone.
|
||||||
The cards of a row share one height (the grid's default stretch), so a row reads
|
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). */
|
as a calm band; the card itself keeps its content at the top (align-content). */
|
||||||
.markdown-body .team > ul {
|
.markdown-body .team > ul {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(17rem, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(17rem, 1fr));
|
||||||
gap: var(--space-3);
|
gap: var(--space-3);
|
||||||
max-width: none;
|
box-sizing: border-box;
|
||||||
|
max-width: 42rem;
|
||||||
margin: 0 0 var(--space-4);
|
margin: 0 0 var(--space-4);
|
||||||
padding: 0;
|
padding: 0;
|
||||||
list-style: none;
|
list-style: none;
|
||||||
@@ -1029,3 +1210,19 @@ h1:focus {
|
|||||||
border: none;
|
border: none;
|
||||||
border-top: 1px solid var(--color-border);
|
border-top: 1px solid var(--color-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Reduced motion: visitors who asked their system for less movement get none.
|
||||||
|
The site has no animations, only short transitions (hover colours, the
|
||||||
|
hamburger turning into an X, the mobile menu folding open); these are cut to
|
||||||
|
an instant here. Near zero rather than none, so anything that waits for a
|
||||||
|
transition to end still gets its event. */
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
*,
|
||||||
|
::before,
|
||||||
|
::after {
|
||||||
|
transition-duration: 0.01ms !important;
|
||||||
|
animation-duration: 0.01ms !important;
|
||||||
|
animation-iteration-count: 1 !important;
|
||||||
|
scroll-behavior: auto !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<!--
|
||||||
|
Static maintenance page shown when PocketBase is unreachable. The gate in Program.cs
|
||||||
|
serves this file with status 503 before any component renders, so it must stand on
|
||||||
|
its own: no menu, no PocketBase, no script, and its styles are inlined rather than
|
||||||
|
linked to the fingerprinted app.css (whose hashed name this static file cannot know).
|
||||||
|
The tokens below are a copy of the ones this page needs from app.css, light and dark,
|
||||||
|
under the same names -- when a colour changes there, change it here too. The header
|
||||||
|
band and the signet mirror MainLayout and Signet.razor, so the page still looks like
|
||||||
|
the site. The font is our own self-hosted Nunito; static files stay reachable while
|
||||||
|
PocketBase is down, the gate only covers pages.
|
||||||
|
-->
|
||||||
|
<html lang="de">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8"/>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||||
|
<meta name="robots" content="noindex"/>
|
||||||
|
<title>Elternbeirat der IGMH – gerade nicht erreichbar</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--color-brand: #2b6f9c;
|
||||||
|
--color-brand-dark: #1f5477;
|
||||||
|
--color-header: #2a77a6;
|
||||||
|
--color-on-header: #fff;
|
||||||
|
--color-accent-dark: #4f9247;
|
||||||
|
--gradient-brand: linear-gradient(135deg, var(--color-header) 0%, #3a7a3e 100%);
|
||||||
|
--color-focus: #1f5477;
|
||||||
|
--color-heading: #2b6f9c;
|
||||||
|
--color-text: #222;
|
||||||
|
--color-text-soft: #555;
|
||||||
|
--color-bg: #fff;
|
||||||
|
--color-page: #f4f6f4;
|
||||||
|
--color-border: #e0e0e0;
|
||||||
|
|
||||||
|
--space-2: 0.5rem;
|
||||||
|
--space-3: 1rem;
|
||||||
|
--space-4: 1.5rem;
|
||||||
|
--space-5: 2rem;
|
||||||
|
--radius: 12px;
|
||||||
|
--shadow: 0 1px 3px rgba(31, 58, 95, 0.08), 0 4px 12px rgba(31, 58, 95, 0.06);
|
||||||
|
|
||||||
|
--font-family: "Nunito", system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||||
|
--font-size-body: 1.0625rem;
|
||||||
|
--font-weight-heading: 800;
|
||||||
|
--letter-spacing-heading: -0.015em;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
--color-brand: #7cc0ec;
|
||||||
|
--color-brand-dark: #a3d5f4;
|
||||||
|
--color-header: #266f9c;
|
||||||
|
--color-accent-dark: #86c97d;
|
||||||
|
--gradient-brand: linear-gradient(135deg, var(--color-header) 0%, #2f6b33 100%);
|
||||||
|
--color-focus: #a3d5f4;
|
||||||
|
--color-heading: #c9ced6;
|
||||||
|
--color-text: #a2a8b2;
|
||||||
|
--color-text-soft: #a2a8b2;
|
||||||
|
--color-bg: #222834;
|
||||||
|
--color-page: #171b22;
|
||||||
|
--color-border: #333b47;
|
||||||
|
--shadow: 0 1px 3px rgba(0, 0, 0, 0.4), 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Absolute path: this file is served for any page URL, e.g. /posts/new-board,
|
||||||
|
where a relative "fonts/..." would point into /posts/. */
|
||||||
|
@font-face {
|
||||||
|
font-family: "Nunito";
|
||||||
|
src: url("/fonts/nunito-latin-wght.woff2") format("woff2");
|
||||||
|
font-weight: 400 800;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
font-family: var(--font-family);
|
||||||
|
line-height: 1.65;
|
||||||
|
color-scheme: light dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100dvh;
|
||||||
|
background: var(--color-page);
|
||||||
|
color: var(--color-text);
|
||||||
|
font-size: var(--font-size-body);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The header band of MainLayout without the menu: there is nothing to
|
||||||
|
navigate to while the pages are down. */
|
||||||
|
header {
|
||||||
|
background: var(--gradient-brand);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-inner {
|
||||||
|
max-width: 72rem;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: var(--space-3) 1.25rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
color: var(--color-on-header);
|
||||||
|
}
|
||||||
|
|
||||||
|
.signet {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 2.5rem;
|
||||||
|
height: 2.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signet-bubble {
|
||||||
|
fill: var(--color-on-header);
|
||||||
|
}
|
||||||
|
|
||||||
|
.signet-figures {
|
||||||
|
fill: var(--color-accent-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-text {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
line-height: 1.15;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-name {
|
||||||
|
font-size: 1.35rem;
|
||||||
|
font-weight: var(--font-weight-heading);
|
||||||
|
letter-spacing: var(--letter-spacing-heading);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-school {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
box-sizing: border-box;
|
||||||
|
max-width: 36rem;
|
||||||
|
margin: var(--space-5) auto;
|
||||||
|
padding: 0 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: var(--color-bg);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
padding: var(--space-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin: 0 0 var(--space-3);
|
||||||
|
color: var(--color-heading);
|
||||||
|
font-size: 1.5rem;
|
||||||
|
line-height: 1.25;
|
||||||
|
font-weight: var(--font-weight-heading);
|
||||||
|
letter-spacing: var(--letter-spacing-heading);
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: var(--color-brand);
|
||||||
|
font-weight: 600;
|
||||||
|
text-underline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:hover {
|
||||||
|
color: var(--color-brand-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
a:focus-visible {
|
||||||
|
outline: 3px solid var(--color-focus);
|
||||||
|
outline-offset: 2px;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact {
|
||||||
|
margin-top: var(--space-4);
|
||||||
|
padding-top: var(--space-4);
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
color: var(--color-text-soft);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<div class="header-inner">
|
||||||
|
<!-- Not a link: the start page is down as well, it would only lead here. -->
|
||||||
|
<svg class="signet" viewBox="0 0 40 40" width="40" height="40" aria-hidden="true" focusable="false">
|
||||||
|
<path class="signet-bubble"
|
||||||
|
d="M8 4h24a6 6 0 0 1 6 6v15a6 6 0 0 1-6 6H18l-8 6v-6H8a6 6 0 0 1-6-6V10a6 6 0 0 1 6-6z"/>
|
||||||
|
<g class="signet-figures">
|
||||||
|
<circle cx="15" cy="12" r="3.5"/>
|
||||||
|
<path d="M9 26c0-5 2.7-8 6-8s6 3 6 8z"/>
|
||||||
|
<circle cx="26" cy="16" r="2.6"/>
|
||||||
|
<path d="M21.5 26c0-3.8 2-6 4.5-6s4.5 2.2 4.5 6z"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
<span class="brand-text">
|
||||||
|
<span class="brand-name">Elternbeirat</span>
|
||||||
|
<span class="brand-school">IGMH Mannheim</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<div class="card">
|
||||||
|
<h1>Die Seite ist gerade nicht erreichbar</h1>
|
||||||
|
<p>
|
||||||
|
Wir bitten um Entschuldigung. Die Website des Elternbeirats der IGMH ist im
|
||||||
|
Moment nicht verfügbar. Wir arbeiten daran, bitte versuchen Sie es in Kürze
|
||||||
|
noch einmal.
|
||||||
|
</p>
|
||||||
|
<p class="contact">
|
||||||
|
In dringenden Fällen erreichen Sie den Elternbeirat per E-Mail:<br/>
|
||||||
|
<a href="mailto:kontakt@elternbeirat-igmh.info">kontakt@elternbeirat-igmh.info</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
@@ -128,6 +128,31 @@ development and the tests and are **not** used on Unraid — see `entwicklung.md
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Outage: PocketBase unreachable
|
||||||
|
|
||||||
|
If `eb-pocketbase` is down while `eb-blazor` runs, the app does not render
|
||||||
|
half-empty pages (the menu comes from PocketBase too). A gate in `Program.cs`
|
||||||
|
answers every **page** request with the static `wwwroot/maintenance.html`:
|
||||||
|
|
||||||
|
| Request | Answer while PocketBase is down |
|
||||||
|
|---|---|
|
||||||
|
| Any page (`/`, `/board`, `/posts/…`, unknown slugs) | **503**, `Retry-After: 60`, maintenance page |
|
||||||
|
| `/health` | **503** `PocketBase unreachable` (for Uptime Kuma) |
|
||||||
|
| `/events.ics` | 200 with an empty calendar, so subscriptions keep working |
|
||||||
|
| `/events/{id}.ics` | 503 |
|
||||||
|
| Static files (CSS, fonts, PDFs, `/maintenance.html`) | served as usual |
|
||||||
|
|
||||||
|
The gate caches the health check for **10 seconds** (`PocketBaseHealthCache`). So
|
||||||
|
after PocketBase stops, pages can render for up to 10 s more (the components
|
||||||
|
degrade on their own in that window: empty menu, a short note, 503), and after it
|
||||||
|
starts again the maintenance page can stay up for up to 10 s. No restart of the
|
||||||
|
web app is needed — the site comes back by itself.
|
||||||
|
|
||||||
|
`maintenance.html` carries its styles inline, as a copy of the tokens from
|
||||||
|
`app.css`. When colours change there, copy them over.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Rollback
|
## Rollback
|
||||||
|
|
||||||
`scripts/release.sh` additionally tags each release with the short commit SHA,
|
`scripts/release.sh` additionally tags each release with the short commit SHA,
|
||||||
|
|||||||
Reference in new issue
Block a user