Fix/pocketbase date timezone #33

Merged
Tom merged 9 commits from fix/pocketbase-date-timezone into main 2026-09-24 14:50:11 +02:00
13 changed files with 157 additions and 81 deletions
Showing only changes of commit 0f7e968c16 - Show all commits

No files matched your search

+6
View File
@@ -54,3 +54,9 @@ dotnet_diagnostic.CA1812.severity = none
# layout and is harmless (Blazor routing is not namespace-based). Renaming would # layout and is harmless (Blazor routing is not namespace-based). Renaming would
# break the folder convention for no benefit. # break the folder convention for no benefit.
dotnet_diagnostic.CA1724.severity = none dotnet_diagnostic.CA1724.severity = none
# CA1308 wants ToUpperInvariant over ToLowerInvariant. That advice is for
# normalizing before a comparison, where a few characters do not round-trip. Here
# lowercasing is the goal itself (URL slugs, which must be lowercase), not a step
# before a comparison -- comparisons use StringComparison.OrdinalIgnoreCase instead.
dotnet_diagnostic.CA1308.severity = none
+16 -11
View File
@@ -14,8 +14,13 @@ public record Event
/// <summary> /// <summary>
/// Gets the PocketBase record id. /// Gets the PocketBase record id.
/// </summary> /// </summary>
/// <remarks>
/// <see langword="required"/>: every stored record has an id. Modeling it as
/// required states which fields a record must carry, independent of the store,
/// so a future data source has to supply them too.
/// </remarks>
[JsonPropertyName("id")] [JsonPropertyName("id")]
public string Id { get; init; } = ""; public required string Id { get; init; }
/// <summary> /// <summary>
/// Gets the start of the event as Europe/Berlin wall-clock time. /// Gets the start of the event as Europe/Berlin wall-clock time.
@@ -24,10 +29,12 @@ public record Event
/// PocketBase stores the value in UTC; it is converted to Berlin local time on /// PocketBase stores the value in UTC; it is converted to Berlin local time on
/// deserialization, with daylight saving applied. An all-day event uses 00:00 /// deserialization, with daylight saving applied. An all-day event uses 00:00
/// as the time. /// as the time.
/// <see langword="required"/>: an event without a start has no place on the
/// calendar, and the field is required in PocketBase.
/// </remarks> /// </remarks>
[JsonPropertyName("start")] [JsonPropertyName("start")]
[JsonConverter(typeof(LocalDateTimeConverter))] [JsonConverter(typeof(LocalDateTimeConverter))]
public DateTime Start { get; init; } public required DateTime Start { get; init; }
/// <summary> /// <summary>
/// Gets the optional end of the event as Europe/Berlin wall-clock time. /// Gets the optional end of the event as Europe/Berlin wall-clock time.
@@ -42,8 +49,12 @@ public record Event
/// <summary> /// <summary>
/// Gets the event name, e.g. <c>"Elternbeiratssitzung"</c>. /// Gets the event name, e.g. <c>"Elternbeiratssitzung"</c>.
/// </summary> /// </summary>
/// <remarks>
/// <see langword="required"/>: an event without a name is incomplete, and the
/// field is required in PocketBase.
/// </remarks>
[JsonPropertyName("title")] [JsonPropertyName("title")]
public string Title { get; init; } = ""; public required string Title { get; init; }
/// <summary> /// <summary>
/// Gets the optional location, e.g. <c>"Aula"</c>. /// Gets the optional location, e.g. <c>"Aula"</c>.
@@ -52,7 +63,7 @@ public record Event
/// The location, or <see cref="string.Empty"/> if none is set. /// The location, or <see cref="string.Empty"/> if none is set.
/// </value> /// </value>
[JsonPropertyName("location")] [JsonPropertyName("location")]
public string Location { get; init; } = ""; public string Location { get; init; } = string.Empty;
/// <summary> /// <summary>
/// Gets the optional note, e.g. <c>"Anmeldung erforderlich"</c>. /// Gets the optional note, e.g. <c>"Anmeldung erforderlich"</c>.
@@ -61,13 +72,7 @@ public record Event
/// The note, or <see cref="string.Empty"/> if none is set. /// The note, or <see cref="string.Empty"/> if none is set.
/// </value> /// </value>
[JsonPropertyName("note")] [JsonPropertyName("note")]
public string Note { get; init; } = ""; public string Note { get; init; } = string.Empty;
/// <summary>
/// Gets a value indicating whether the event is visible to visitors of the site.
/// </summary>
[JsonPropertyName("public")]
public bool Public { get; init; }
/// <summary> /// <summary>
/// Gets a value indicating whether the event has a wall-clock time, as opposed /// Gets a value indicating whether the event has a wall-clock time, as opposed
+21 -10
View File
@@ -13,30 +13,41 @@ public record Faq
/// <summary> /// <summary>
/// Gets the PocketBase record id. /// Gets the PocketBase record id.
/// </summary> /// </summary>
/// <remarks>
/// <see langword="required"/>: every stored record has an id. Modeling it as
/// required states which fields a record must carry, independent of the store,
/// so a future data source has to supply them too.
/// </remarks>
[JsonPropertyName("id")] [JsonPropertyName("id")]
public string Id { get; init; } = ""; public required string Id { get; init; }
/// <summary> /// <summary>
/// Gets the question as a parent would phrase it. /// Gets the question as a parent would phrase it.
/// </summary> /// </summary>
/// <remarks>
/// <see langword="required"/>: a FAQ entry without a question is incomplete, and
/// the field is required in PocketBase.
/// </remarks>
[JsonPropertyName("question")] [JsonPropertyName("question")]
public string Question { get; init; } = ""; public required string Question { get; init; }
/// <summary> /// <summary>
/// Gets the answer as Markdown. /// Gets the answer as Markdown.
/// </summary> /// </summary>
/// <remarks>
/// <see langword="required"/>: a FAQ entry exists to answer its question, so an
/// entry without an answer is incomplete. The field is required in PocketBase.
/// </remarks>
[JsonPropertyName("answer")] [JsonPropertyName("answer")]
public string Answer { get; init; } = ""; public required string Answer { get; init; }
/// <summary> /// <summary>
/// Gets the topic the question is grouped under, e.g. <c>"mensa"</c>. /// Gets the topic the question is grouped under, e.g. <c>"mensa"</c>.
/// </summary> /// </summary>
/// <remarks>
/// <see langword="required"/>: the FAQ page groups entries by topic, so an entry
/// without one has no place. The field is required in PocketBase.
/// </remarks>
[JsonPropertyName("topic")] [JsonPropertyName("topic")]
public string Topic { get; init; } = ""; public required string Topic { get; init; }
/// <summary>
/// Gets a value indicating whether the question is visible to visitors of the site.
/// </summary>
[JsonPropertyName("public")]
public bool Public { get; init; }
} }
+34 -17
View File
@@ -22,23 +22,30 @@ public record Page
/// <summary> /// <summary>
/// Gets the PocketBase record id. /// Gets the PocketBase record id.
/// </summary> /// </summary>
/// <remarks>
/// <see langword="required"/>: every stored record has an id. Modeling it as
/// required states which fields a record must carry, independent of the store,
/// so a future data source has to supply them too.
/// </remarks>
[JsonPropertyName("id")] [JsonPropertyName("id")]
public string Id { get; init; } = ""; public required string Id { get; init; }
/// <summary> /// <summary>
/// Gets the heading shown to visitors. /// Gets the heading shown to visitors.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// Free text; may contain umlauts and spaces. For the URL, see <see cref="Slug"/>. /// Free text; may contain umlauts and spaces. For the URL, see <see cref="Slug"/>.
/// <see langword="required"/>: a page without a heading is incomplete, and the
/// field is required in PocketBase.
/// </remarks> /// </remarks>
[JsonPropertyName("title")] [JsonPropertyName("title")]
public string Title { get; init; } = ""; public required string Title { get; init; }
/// <summary> /// <summary>
/// Gets the page body as Markdown. /// Gets the page body as Markdown.
/// </summary> /// </summary>
[JsonPropertyName("body")] [JsonPropertyName("body")]
public string Body { get; init; } = ""; public string Body { get; init; } = string.Empty;
/// <summary> /// <summary>
/// Gets the navigation menu the page appears in. /// Gets the navigation menu the page appears in.
@@ -46,8 +53,12 @@ public record Page
/// <value> /// <value>
/// Either <c>"header"</c> or <c>"footer"</c>. /// Either <c>"header"</c> or <c>"footer"</c>.
/// </value> /// </value>
/// <remarks>
/// <see langword="required"/>: the navigation is built from this, and the field
/// is required in PocketBase, so every page belongs to one of the two menus.
/// </remarks>
[JsonPropertyName("location")] [JsonPropertyName("location")]
public string Location { get; init; } = ""; public required string Location { get; init; }
/// <summary> /// <summary>
/// Gets the sort order within the navigation menu given by <see cref="Location"/>. /// Gets the sort order within the navigation menu given by <see cref="Location"/>.
@@ -62,10 +73,18 @@ public record Page
/// Gets the URL slug of the page, e.g. <c>"board"</c> for <c>/board</c>. /// Gets the URL slug of the page, e.g. <c>"board"</c> for <c>/board</c>.
/// </summary> /// </summary>
/// <value> /// <value>
/// A lowercase path segment without umlauts. /// A URL path segment: lowercase letters, digits and single hyphens as
/// separators, no umlauts. PocketBase enforces this on save via a field
/// pattern (<c>^[a-z0-9]+(-[a-z0-9]+)*$</c>), so every stored slug is already
/// canonical and the app can use it verbatim in comparisons and generated URLs.
/// </value> /// </value>
/// <remarks>
/// <see langword="required"/>: the slug is how a page is addressed, so a record
/// without one is broken. It is a required field in PocketBase, so deserializing
/// one that lacks it should fail loudly rather than yield a page with no URL.
/// </remarks>
[JsonPropertyName("slug")] [JsonPropertyName("slug")]
public string Slug { get; init; } = ""; public required string Slug { get; init; }
/// <summary> /// <summary>
/// Gets the dynamic blocks rendered below the <see cref="Body"/>. /// Gets the dynamic blocks rendered below the <see cref="Body"/>.
@@ -78,22 +97,20 @@ public record Page
public IReadOnlyList<string> Embed { get; init; } = []; public IReadOnlyList<string> Embed { get; init; } = [];
/// <summary> /// <summary>
/// Gets a value indicating whether the page is visible to visitors of the site. /// Tests whether a slug is the home page's. This is the single definition of
/// </summary> /// that comparison, so every caller identifies the home page the same way.
[JsonPropertyName("public")]
public bool Public { get; init; }
/// <summary>
/// Tests whether a slug is the home page's, ignoring case. This is the single
/// definition of that comparison, so every caller treats <c>home</c> and
/// <c>Home</c> alike.
/// </summary> /// </summary>
/// <remarks>
/// An ordinal (case-sensitive) comparison is enough because slugs are always
/// lowercase: PocketBase enforces that with a field pattern (see
/// <see cref="Slug"/>), and this is only ever called with a stored slug.
/// </remarks>
/// <param name="slug"> /// <param name="slug">
/// The slug to test. /// The slug to test, e.g. from <see cref="Slug"/>.
/// </param> /// </param>
/// <returns> /// <returns>
/// <see langword="true"/> if <paramref name="slug"/> is the home page's slug. /// <see langword="true"/> if <paramref name="slug"/> is the home page's slug.
/// </returns> /// </returns>
public static bool IsHomeSlug(string slug) => public static bool IsHomeSlug(string slug) =>
string.Equals(slug, HomeSlug, StringComparison.OrdinalIgnoreCase); string.Equals(slug, HomeSlug, StringComparison.Ordinal);
} }
+20 -11
View File
@@ -13,8 +13,13 @@ public record Post
/// <summary> /// <summary>
/// Gets the PocketBase record id. /// Gets the PocketBase record id.
/// </summary> /// </summary>
/// <remarks>
/// <see langword="required"/>: every stored record has an id. Modeling it as
/// required states which fields a record must carry, independent of the store,
/// so a future data source has to supply them too.
/// </remarks>
[JsonPropertyName("id")] [JsonPropertyName("id")]
public string Id { get; init; } = ""; public required string Id { get; init; }
/// <summary> /// <summary>
/// Gets the publication date as a calendar day. /// Gets the publication date as a calendar day.
@@ -34,28 +39,32 @@ public record Post
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// Free text; may contain umlauts and spaces. For the URL, see <see cref="Slug"/>. /// Free text; may contain umlauts and spaces. For the URL, see <see cref="Slug"/>.
/// <see langword="required"/>: a post without a heading is incomplete, and the
/// field is required in PocketBase.
/// </remarks> /// </remarks>
[JsonPropertyName("title")] [JsonPropertyName("title")]
public string Title { get; init; } = ""; public required string Title { get; init; }
/// <summary> /// <summary>
/// Gets the post body as Markdown. /// Gets the post body as Markdown.
/// </summary> /// </summary>
[JsonPropertyName("body")] [JsonPropertyName("body")]
public string Body { get; init; } = ""; public string Body { get; init; } = string.Empty;
/// <summary> /// <summary>
/// Gets the URL slug of the post, e.g. <c>"herbstbasar"</c> for <c>/posts/herbstbasar</c>. /// Gets the URL slug of the post, e.g. <c>"herbstbasar"</c> for <c>/posts/herbstbasar</c>.
/// </summary> /// </summary>
/// <value> /// <value>
/// A lowercase path segment without umlauts. /// A URL path segment: lowercase letters, digits and single hyphens as
/// separators, no umlauts. PocketBase enforces this on save via a field
/// pattern (<c>^[a-z0-9]+(-[a-z0-9]+)*$</c>), so every stored slug is already
/// canonical and the app can use it verbatim in comparisons and generated URLs.
/// </value> /// </value>
/// <remarks>
/// <see langword="required"/>: the slug is how a post is addressed, so a record
/// without one is broken. It is a required field in PocketBase, so deserializing
/// one that lacks it should fail loudly rather than yield a post with no URL.
/// </remarks>
[JsonPropertyName("slug")] [JsonPropertyName("slug")]
public string Slug { get; init; } = ""; public required string Slug { get; init; }
/// <summary>
/// Gets a value indicating whether the post is visible to visitors of the site.
/// </summary>
[JsonPropertyName("public")]
public bool Public { get; init; }
} }
+15 -15
View File
@@ -24,15 +24,15 @@ namespace Elternbeirat.PocketBase;
/// <see cref="HttpClient.BaseAddress"/> set to the PocketBase base URL. /// <see cref="HttpClient.BaseAddress"/> set to the PocketBase base URL.
/// </para> /// </para>
/// </remarks> /// </remarks>
/// <param name="http"> /// <param name="httpClient">
/// The HTTP client; its <see cref="HttpClient.BaseAddress"/> must point to PocketBase. /// The HTTP client; its <see cref="HttpClient.BaseAddress"/> must point to PocketBase.
/// </param> /// </param>
public sealed class PocketBaseClient(HttpClient http) public sealed class PocketBaseClient(HttpClient httpClient)
{ {
/// <summary> /// <summary>
/// Gets all public pages, ordered by <see cref="Page.Order"/>. /// Gets all public pages, ordered by <see cref="Page.Order"/>.
/// </summary> /// </summary>
/// <param name="ct"> /// <param name="token">
/// A token to cancel the request. /// A token to cancel the request.
/// </param> /// </param>
/// <returns> /// <returns>
@@ -45,10 +45,10 @@ public sealed class PocketBaseClient(HttpClient http)
/// The response could not be deserialized. /// The response could not be deserialized.
/// </exception> /// </exception>
/// <exception cref="OperationCanceledException"> /// <exception cref="OperationCanceledException">
/// <paramref name="ct"/> was canceled or the request timed out. /// <paramref name="token"/> was canceled or the request timed out.
/// </exception> /// </exception>
public Task<IReadOnlyList<Page>> GetPagesAsync(CancellationToken ct = default) public Task<IReadOnlyList<Page>> GetPagesAsync(CancellationToken token = default)
=> GetRecordsAsync<Page>("pages", "order", ct); => GetRecordsAsync<Page>("pages", "order", token);
/// <summary> /// <summary>
/// Gets all public posts, ordered by <see cref="Post.Date"/>, newest first. /// Gets all public posts, ordered by <see cref="Post.Date"/>, newest first.
@@ -58,8 +58,8 @@ public sealed class PocketBaseClient(HttpClient http)
/// The public posts, or an empty list if there are none. /// The public posts, or an empty list if there are none.
/// </returns> /// </returns>
/// <inheritdoc cref="GetPagesAsync" path="/exception"/> /// <inheritdoc cref="GetPagesAsync" path="/exception"/>
public Task<IReadOnlyList<Post>> GetPostsAsync(CancellationToken ct = default) public Task<IReadOnlyList<Post>> GetPostsAsync(CancellationToken token = default)
=> GetRecordsAsync<Post>("posts", "-date", ct); => GetRecordsAsync<Post>("posts", "-date", token);
/// <summary> /// <summary>
/// Gets all public events, ordered by <see cref="Event.Start"/>, earliest first. /// Gets all public events, ordered by <see cref="Event.Start"/>, earliest first.
@@ -69,8 +69,8 @@ public sealed class PocketBaseClient(HttpClient http)
/// The public events, or an empty list if there are none. /// The public events, or an empty list if there are none.
/// </returns> /// </returns>
/// <inheritdoc cref="GetPagesAsync" path="/exception"/> /// <inheritdoc cref="GetPagesAsync" path="/exception"/>
public Task<IReadOnlyList<Event>> GetEventsAsync(CancellationToken ct = default) public Task<IReadOnlyList<Event>> GetEventsAsync(CancellationToken token = default)
=> GetRecordsAsync<Event>("events", "start", ct); => GetRecordsAsync<Event>("events", "start", token);
/// <summary> /// <summary>
/// Gets all public FAQ entries, ordered by <see cref="Faq.Topic"/>. /// Gets all public FAQ entries, ordered by <see cref="Faq.Topic"/>.
@@ -80,8 +80,8 @@ public sealed class PocketBaseClient(HttpClient http)
/// The public FAQ entries, or an empty list if there are none. /// The public FAQ entries, or an empty list if there are none.
/// </returns> /// </returns>
/// <inheritdoc cref="GetPagesAsync" path="/exception"/> /// <inheritdoc cref="GetPagesAsync" path="/exception"/>
public Task<IReadOnlyList<Faq>> GetFaqsAsync(CancellationToken ct = default) public Task<IReadOnlyList<Faq>> GetFaqsAsync(CancellationToken token = default)
=> GetRecordsAsync<Faq>("faqs", "topic", ct); => GetRecordsAsync<Faq>("faqs", "topic", token);
/// <summary> /// <summary>
/// Gets all public records of a collection in a single request. /// Gets all public records of a collection in a single request.
@@ -95,7 +95,7 @@ public sealed class PocketBaseClient(HttpClient http)
/// <param name="sort"> /// <param name="sort">
/// The PocketBase sort expression; a leading <c>-</c> sorts descending. /// The PocketBase sort expression; a leading <c>-</c> sorts descending.
/// </param> /// </param>
/// <param name="ct"> /// <param name="token">
/// A token to cancel the request. /// A token to cancel the request.
/// </param> /// </param>
/// <returns> /// <returns>
@@ -104,7 +104,7 @@ public sealed class PocketBaseClient(HttpClient http)
private async Task<IReadOnlyList<T>> GetRecordsAsync<T>( private async Task<IReadOnlyList<T>> GetRecordsAsync<T>(
string collection, string collection,
string sort, string sort,
CancellationToken ct) CancellationToken token)
{ {
// filter=public=true keeps drafts out; perPage is large enough to fetch // filter=public=true keeps drafts out; perPage is large enough to fetch
// every record in a single page given the small content volume. // every record in a single page given the small content volume.
@@ -112,7 +112,7 @@ public sealed class PocketBaseClient(HttpClient http)
+ $"?perPage=500&filter={Uri.EscapeDataString("public=true")}" + $"?perPage=500&filter={Uri.EscapeDataString("public=true")}"
+ $"&sort={Uri.EscapeDataString(sort)}"; + $"&sort={Uri.EscapeDataString(sort)}";
var result = await http.GetFromJsonAsync<RecordList<T>>(url, ct); var result = await httpClient.GetFromJsonAsync<RecordList<T>>(url, token);
return result?.Items ?? []; return result?.Items ?? [];
} }
} }
+3 -1
View File
@@ -8,8 +8,10 @@ namespace Elternbeirat.Web.Tests;
/// </summary> /// </summary>
public class NavBuilderTests public class NavBuilderTests
{ {
// Id is required on the record but irrelevant to navigation, so a fixed
// placeholder keeps these cases focused on slug/location/order/title.
private static Page Page(string slug, string location, double order, string title) => private static Page Page(string slug, string location, double order, string title) =>
new() { Slug = slug, Location = location, Order = order, Title = title }; new() { Id = "test", Slug = slug, Location = location, Order = order, Title = title };
[Fact] [Fact]
public void Header_keeps_only_header_pages_ordered_by_order() public void Header_keeps_only_header_pages_ordered_by_order()
@@ -19,7 +19,6 @@ public sealed class PocketBaseClientTests(PocketBaseFixture pocketBase)
// Two seeded events, earliest start first. // Two seeded events, earliest start first.
events.Select(e => e.Title).ShouldBe(["Elternbeiratssitzung", "Herbstbasar"]); events.Select(e => e.Title).ShouldBe(["Elternbeiratssitzung", "Herbstbasar"]);
events.ShouldAllBe(e => e.Public);
} }
[Fact] [Fact]
+1 -1
View File
@@ -335,6 +335,6 @@ public sealed class PocketBaseFixture : IAsyncLifetime
private sealed record AuthResponse private sealed record AuthResponse
{ {
[System.Text.Json.Serialization.JsonPropertyName("token")] [System.Text.Json.Serialization.JsonPropertyName("token")]
public string Token { get; init; } = ""; public string Token { get; init; } = string.Empty;
} }
} }
@@ -2,23 +2,27 @@ using Elternbeirat.Contracts;
using Elternbeirat.PocketBase; using Elternbeirat.PocketBase;
using Elternbeirat.Web.Shared; using Elternbeirat.Web.Shared;
using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Http;
namespace Elternbeirat.Web.Features.Pages; namespace Elternbeirat.Web.Features.Pages;
public partial class ContentPage public partial class ContentPage
{ {
[LoggerMessage(Level = LogLevel.Warning, Message = "Could not load page '{slug}' from PocketBase.")] [LoggerMessage(
private static partial void LogLoadFailed(ILogger logger, string slug, Exception exception); Level = LogLevel.Warning,
Message = "Could not load page '{slug}' from PocketBase.")]
private static partial void LogLoadFailed(
ILogger logger,
string slug,
Exception exception);
[Parameter] [Parameter]
public string Slug { get; set; } = ""; public string Slug { get; set; } = string.Empty;
[Inject] [Inject]
private PocketBaseClient PocketBase { get; set; } = default!; private PocketBaseClient PocketBase { get; set; } = null!;
[Inject] [Inject]
private ILogger<ContentPage> Logger { get; set; } = default!; private ILogger<ContentPage> Logger { get; set; } = null!;
[CascadingParameter] [CascadingParameter]
private HttpContext? HttpContext { get; set; } private HttpContext? HttpContext { get; set; }
@@ -15,7 +15,7 @@ public partial class PostDetail
private static partial void LogLoadFailed(ILogger logger, string slug, Exception exception); private static partial void LogLoadFailed(ILogger logger, string slug, Exception exception);
[Parameter] [Parameter]
public string Slug { get; set; } = ""; public string Slug { get; set; } = string.Empty;
[Inject] [Inject]
private PocketBaseClient PocketBase { get; set; } = default!; private PocketBaseClient PocketBase { get; set; } = default!;
+26 -3
View File
@@ -87,8 +87,9 @@ sofort live, ohne Code und ohne Rebuild:
5. Optional `location` (`header`/`footer`) **und** `order` setzen, damit die Seite 5. Optional `location` (`header`/`footer`) **und** `order` setzen, damit die Seite
ins Menü kommt; optional `embed` für eingebettete Blöcke (siehe unten). ins Menü kommt; optional `embed` für eingebettete Blöcke (siehe unten).
- **Slug bleibt englisch und ohne Umlaute.** `imprint`, `privacy`, `contact`, - **Slug bleibt englisch und ohne Umlaute** — `imprint`, `privacy`, `contact`,
`board`, `patrons`. Der `title` darf deutsch mit Umlauten sein (`Förderverein`). `board`, `patrons`; erzwungen per Pattern (siehe *Slug-Regel wird erzwungen*
unten). Der `title` darf deutsch mit Umlauten sein (`Förderverein`).
- **Reservierte Slugs.** `posts`, `events`, `faqs` und `/` (die Startseite `home`) - **Reservierte Slugs.** `posts`, `events`, `faqs` und `/` (die Startseite `home`)
gehören den festen Listen-/Sonderansichten. Eine `pages`-Seite mit einem dieser gehören den festen Listen-/Sonderansichten. Eine `pages`-Seite mit einem dieser
Slugs wird von der jeweiligen Ansicht verdeckt und **nicht** angezeigt; in der Slugs wird von der jeweiligen Ansicht verdeckt und **nicht** angezeigt; in der
@@ -105,6 +106,26 @@ sofort live, ohne Code und ohne Rebuild:
> Diese Slugs nicht ändern — sonst laufen die rechtlich verlinkten Adressen ins > Diese Slugs nicht ändern — sonst laufen die rechtlich verlinkten Adressen ins
> Leere (404). > Leere (404).
### Slug-Regel wird erzwungen
Ein Slug muss ein sauberes URL-Segment sein: **Kleinbuchstaben, Ziffern und
einzelne Bindestriche als Trenner, keine Umlaute** (`ueber-uns`, nicht `Über uns`).
Das ist keine Bitte, sondern eine Feldregel: PocketBase weist einen Slug ab, der
sie verletzt, und zeigt beim Speichern einen Fehler. Gibst du `Overview` ein,
kommst du erst weiter, wenn du `overview` daraus machst. Die Website verlässt sich
darauf — sie repariert einen Slug nicht mehr nachträglich.
Die Regel steckt als **Pattern** am Feld `slug`. So ist sie gesetzt (einmalig,
pro Collection mit einem Slug — `pages` **und** `posts`):
1. Collection öffnen → *Edit collection* (oben rechts) → Feld `slug`.
2. Am Feld auf das **Zahnrad** (Feldoptionen) → **Pattern**.
3. Als Pattern eintragen: `^[a-z0-9]+(-[a-z0-9]+)*$`
4. **Save** (Feld) und **Save** (Collection).
Zum Prüfen: `board`, `ueber-uns`, `faq-lunch` werden angenommen; `Overview`,
`über-uns`, `my page`, `-x` und `a--b` werden abgewiesen.
--- ---
## Beiträge (`posts`) ## Beiträge (`posts`)
@@ -116,7 +137,9 @@ der Startseite als Vorschau angeteasert.
- `date` steuert Sortierung und angezeigtes Datum. Anders als bei Terminen zählt - `date` steuert Sortierung und angezeigtes Datum. Anders als bei Terminen zählt
hier nur der **Tag** — eine Uhrzeit wird nie angezeigt, und die UTC-Verschiebung hier nur der **Tag** — eine Uhrzeit wird nie angezeigt, und die UTC-Verschiebung
aus dem Termine-Hinweis spielt keine Rolle. Trage einfach das Datum ein. aus dem Termine-Hinweis spielt keine Rolle. Trage einfach das Datum ein.
- `slug` ist englisch, klein, ohne Umlaute (z. B. `new-sports-hall-opened`). - `slug` ist englisch, klein, ohne Umlaute (z. B. `new-sports-hall-opened`) —
dieselbe erzwungene Feldregel wie bei Seiten (siehe *Seiten → Slug-Regel wird
erzwungen*).
--- ---
+4 -4
View File
@@ -74,7 +74,7 @@
"max": 0, "max": 0,
"min": 0, "min": 0,
"name": "slug", "name": "slug",
"pattern": "", "pattern": "^[a-z0-9]+(-[a-z0-9]+)*$",
"presentable": false, "presentable": false,
"primaryKey": false, "primaryKey": false,
"required": true, "required": true,
@@ -298,7 +298,7 @@
"max": 0, "max": 0,
"min": 0, "min": 0,
"name": "slug", "name": "slug",
"pattern": "", "pattern": "^[a-z0-9]+(-[a-z0-9]+)*$",
"presentable": false, "presentable": false,
"primaryKey": false, "primaryKey": false,
"required": true, "required": true,
@@ -388,7 +388,7 @@
"pattern": "", "pattern": "",
"presentable": false, "presentable": false,
"primaryKey": false, "primaryKey": false,
"required": false, "required": true,
"system": false, "system": false,
"type": "text" "type": "text"
}, },
@@ -399,7 +399,7 @@
"maxSelect": 1, "maxSelect": 1,
"name": "topic", "name": "topic",
"presentable": false, "presentable": false,
"required": false, "required": true,
"system": false, "system": false,
"type": "select", "type": "select",
"values": [ "values": [