Serve FAQs from PocketBase on a grouped /faqs page
This commit is contained in:
1 parent
4136af6417
commit
6b5d9dda55
7 files changed
+127
-7
No files matched your search
@@ -7,10 +7,15 @@
|
|||||||
<list />
|
<list />
|
||||||
</option>
|
</option>
|
||||||
<option name="commandLineOptions" value="--build" />
|
<option name="commandLineOptions" value="--build" />
|
||||||
<!-- Base compose (loaded first); the dev overlay in sourceFilePath wins. -->
|
<!-- Dev overlay: applied LAST so it wins (host ports, local web build, and
|
||||||
|
the pb_data_dev volume replacing the Unraid bind mount). In Rider the
|
||||||
|
sourceFilePath is merged first and secondarySourceFiles after it, so
|
||||||
|
the overlay must sit in secondarySourceFiles, not the other way round --
|
||||||
|
otherwise the base file overrides it and PocketBase mounts the empty
|
||||||
|
/mnt/user prod path locally. -->
|
||||||
<option name="secondarySourceFiles">
|
<option name="secondarySourceFiles">
|
||||||
<list>
|
<list>
|
||||||
<option value="$PROJECT_DIR$/compose.yaml" />
|
<option value="$PROJECT_DIR$/compose.dev.yaml" />
|
||||||
</list>
|
</list>
|
||||||
</option>
|
</option>
|
||||||
<option name="services">
|
<option name="services">
|
||||||
@@ -19,8 +24,8 @@
|
|||||||
<option value="eb-web" />
|
<option value="eb-web" />
|
||||||
</list>
|
</list>
|
||||||
</option>
|
</option>
|
||||||
<!-- Dev overlay (primary = last applied): host ports + local web build. -->
|
<!-- Base compose (production-like), merged first. -->
|
||||||
<option name="sourceFilePath" value="$PROJECT_DIR$/compose.dev.yaml" />
|
<option name="sourceFilePath" value="$PROJECT_DIR$/compose.yaml" />
|
||||||
<option name="upForceRecreate" value="true" />
|
<option name="upForceRecreate" value="true" />
|
||||||
</settings>
|
</settings>
|
||||||
</deployment>
|
</deployment>
|
||||||
|
|||||||
@@ -260,10 +260,12 @@ public sealed class PocketBaseFixture : IAsyncLifetime
|
|||||||
title = "Förderverein", body = "# Förderverein", location = "header",
|
title = "Förderverein", body = "# Förderverein", location = "header",
|
||||||
order = 3, slug = "patrons", embed = Array.Empty<string>(), @public = true,
|
order = 3, slug = "patrons", embed = Array.Empty<string>(), @public = true,
|
||||||
});
|
});
|
||||||
|
// Nav placeholder for the FAQ list (route /faqs is served by FaqList,
|
||||||
|
// which shadows this page). Slug matches production's "faqs" nav entry.
|
||||||
await CreateRecordAsync(http, "pages", new
|
await CreateRecordAsync(http, "pages", new
|
||||||
{
|
{
|
||||||
title = "FAQ", body = "# Häufige Fragen", location = "header",
|
title = "FAQ", body = "# Häufige Fragen", location = "header",
|
||||||
order = 4, slug = "faq", embed = Array.Empty<string>(), @public = true,
|
order = 4, slug = "faqs", embed = Array.Empty<string>(), @public = true,
|
||||||
});
|
});
|
||||||
await CreateRecordAsync(http, "pages", new
|
await CreateRecordAsync(http, "pages", new
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ public sealed class RouteSmokeTests : IDisposable
|
|||||||
"/",
|
"/",
|
||||||
"/board",
|
"/board",
|
||||||
"/patrons",
|
"/patrons",
|
||||||
"/faq",
|
"/faqs",
|
||||||
"/faq-mensa",
|
"/faq-mensa",
|
||||||
"/downloads",
|
"/downloads",
|
||||||
"/contact",
|
"/contact",
|
||||||
@@ -83,4 +83,19 @@ public sealed class RouteSmokeTests : IDisposable
|
|||||||
|
|
||||||
response.StatusCode.ShouldBe(HttpStatusCode.NotFound);
|
response.StatusCode.ShouldBe(HttpStatusCode.NotFound);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Faqs_route_renders_the_grouped_list()
|
||||||
|
{
|
||||||
|
// /faqs is a literal route (FaqList) that must win over the /{Slug}
|
||||||
|
// catch-all content page. Assert it shows the FAQ list, grouped by topic
|
||||||
|
// from the seeded questions, not a content page body.
|
||||||
|
var client = _factory.CreateClient();
|
||||||
|
|
||||||
|
var html = await client.GetStringAsync(new Uri("/faqs", UriKind.Relative));
|
||||||
|
|
||||||
|
html.ShouldContain("Häufige Fragen");
|
||||||
|
html.ShouldContain("Wann gibt es Mittagessen?");
|
||||||
|
html.ShouldContain("Mensa und Mittagessen"); // German topic heading
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -10,7 +10,7 @@
|
|||||||
<NavLink href="/patrons">Förderverein</NavLink>
|
<NavLink href="/patrons">Förderverein</NavLink>
|
||||||
<NavLink href="/posts">Beiträge</NavLink>
|
<NavLink href="/posts">Beiträge</NavLink>
|
||||||
<NavLink href="/events">Termine</NavLink>
|
<NavLink href="/events">Termine</NavLink>
|
||||||
<NavLink href="/faq">FAQ</NavLink>
|
<NavLink href="/faqs">FAQ</NavLink>
|
||||||
<NavLink href="/downloads">Downloads</NavLink>
|
<NavLink href="/downloads">Downloads</NavLink>
|
||||||
<NavLink href="/contact">Kontakt</NavLink>
|
<NavLink href="/contact">Kontakt</NavLink>
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
@page "/faqs"
|
||||||
|
|
||||||
|
<PageTitle>Häufige Fragen</PageTitle>
|
||||||
|
|
||||||
|
<h1>Häufige Fragen</h1>
|
||||||
|
|
||||||
|
@if (_groups.Count == 0)
|
||||||
|
{
|
||||||
|
<p>Zurzeit gibt es keine Fragen.</p>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
@foreach (var group in _groups)
|
||||||
|
{
|
||||||
|
<section class="faq-topic">
|
||||||
|
<h2>@TopicLabel(group.Key)</h2>
|
||||||
|
@foreach (var faq in group)
|
||||||
|
{
|
||||||
|
<details class="faq-item">
|
||||||
|
<summary>@faq.Question</summary>
|
||||||
|
<div class="faq-answer">
|
||||||
|
@((MarkupString)Markdown.ToHtml(faq.Answer))
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
using Elternbeirat.Contracts;
|
||||||
|
using Elternbeirat.PocketBase;
|
||||||
|
using Microsoft.AspNetCore.Components;
|
||||||
|
|
||||||
|
namespace Elternbeirat.Web.Features.Faqs;
|
||||||
|
|
||||||
|
public partial class FaqList
|
||||||
|
{
|
||||||
|
[LoggerMessage(Level = LogLevel.Warning, Message = "Could not load FAQs from PocketBase.")]
|
||||||
|
private static partial void LogLoadFailed(ILogger logger, Exception exception);
|
||||||
|
|
||||||
|
// German heading per topic. Unknown topics fall back to the raw key so a new
|
||||||
|
// topic in PocketBase still shows up rather than being silently dropped.
|
||||||
|
private static readonly Dictionary<string, string> TopicLabels =
|
||||||
|
new(StringComparer.Ordinal)
|
||||||
|
{
|
||||||
|
["mensa"] = "Mensa und Mittagessen",
|
||||||
|
["schliessfach"] = "Schließfächer",
|
||||||
|
["elterneuro"] = "Eltern-Euro",
|
||||||
|
["elternarbeit"] = "Elternarbeit und Mitmachen",
|
||||||
|
};
|
||||||
|
|
||||||
|
[Inject]
|
||||||
|
private PocketBaseClient PocketBase { get; set; } = default!;
|
||||||
|
|
||||||
|
[Inject]
|
||||||
|
private ILogger<FaqList> Logger { get; set; } = default!;
|
||||||
|
|
||||||
|
private IReadOnlyList<IGrouping<string, Faq>> _groups = [];
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var faqs = await PocketBase.GetFaqsAsync();
|
||||||
|
_groups = [.. faqs.GroupBy(faq => faq.Topic)];
|
||||||
|
}
|
||||||
|
catch (HttpRequestException ex)
|
||||||
|
{
|
||||||
|
// Source unreachable -> show an empty page instead of a 500. The page
|
||||||
|
// stays up; the "keine Fragen" note is rendered.
|
||||||
|
LogLoadFailed(Logger, ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string TopicLabel(string topic) =>
|
||||||
|
TopicLabels.TryGetValue(topic, out var label) ? label : topic;
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
.faq-topic {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.faq-topic h2 {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.faq-item {
|
||||||
|
border-bottom: 1px solid #eee;
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.faq-item summary {
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.faq-answer {
|
||||||
|
padding: 0.5rem 0 0.25rem;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
Reference in new issue
Block a user