Adopt b2b.next's client patterns: services, page state, feedback

Ported from the owner's b2b.next and adapted from MudBlazor to the daisyUI
components:
- LocalizableComponentBase provides CascadingState, Navigation, Toast,
  IsLoading/IsDefaultDisabled, WhenOnline/StartOnlinePolling, and after-render
  jobs that actually run.
- CascadingState starts with initialised state and polls the online status
  with a PeriodicTimer. It reports errors through ILoggingService plus a toast,
  and disposes what it holds.
- New pieces:
  - ToastService + ToastHost, the snackbar, with AddResponseError/Success/
    Warning/Exception in SUtility;
  - ErrorViewer, which shows the validation messages plus AdditionalError(s);
  - LoggingService, which writes to the console and the IndexedDB client log.
- HttpService is rebuilt and registered:
  - auth levels Get/Post/Delete, *Anon and *TotallyAnon;
  - retry with backoff;
  - classified failures (FailureCodes) instead of exceptions;
  - HTTP 410 on cancel;
  - the "default" client.
  ReadWebResult and DefaultReadFromJsonAsync read its responses.
- ClientJsonContext is source-generated, and SUtility.DefaultSerializer and
  Blazored.LocalStorage use it.
- WebResult gains a C# 14 IsInvalid extension property.

The code follows the imported rules:
- Every service and async helper takes a CancellationToken last.
- Primary constructors replace constructor-plus-fields.
- MessagesService and IStorage return Task<WebResult> / Task<List<T>>.
  MessagesService is still mock, building its results in memory; the seed
  feed and thread moved to Faker.
- Index and ExpandMessage follow the page pattern: an offline guard with
  WhenOnline, LoadData, and early returns with toasts on the results they used
  to ignore. ExpandMessage reloads on route change and shows "not found"
  instead of a null message.
- Model and state collections are [], and the fake identity defaults moved
  out of the models.
- Components:
  - no ?. chains over initialised state;
  - [Inject] instead of @inject;
  - IsDefaultDisabled on the actions;
  - autocomplete on every text input;
  - Localizer[key, args] instead of string.Format;
  - the localizer no longer leaks its inner _pLocalizer.
- Settings → General gains a language picker (English/Italiano), and
  SetDefaultCulture limits the culture to those two.

Bugs fixed along the way:
- A compose with untouched (null) content passed the empty check.
- File errors were joined with <br/> and rendered as markup.
- ClientLogs.ErrorMessage() threw on warnings.
- CoalescingStringLocalizer[key, args] showed a raw "{0}" for missing keys.
- AppStatusService exposed a call to a JS function that doesn't exist.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
This commit is contained in:
thepraandClaude Opus 5.5 committed 2026-10-03 15:26:21 +02:00
1 parent ca060f25fb
commit 95428430c0
55 files changed
+1844 -1337

No files matched your search

+28 -28
View File
@@ -11,31 +11,31 @@
<div class="flex flex-wrap items-center gap-2">
@foreach (var color in Enum.GetValues<DColor>())
{
<DInput @bind-Value="text" Color="color" placeholder="@color.ToString()" class="w-36" />
<DInput autocomplete="off" @bind-Value="text" Color="color" placeholder="@color.ToString()" class="w-36" />
}
</div>
<div class="flex flex-wrap items-center gap-2">
<DInput @bind-Value="text" Size="DSize.Xs" placeholder="Xs" class="w-28" />
<DInput @bind-Value="text" Size="DSize.Sm" placeholder="Sm" class="w-28" />
<DInput @bind-Value="text" placeholder="Md" class="w-28" />
<DInput @bind-Value="text" Size="DSize.Lg" placeholder="Lg" class="w-28" />
<DInput @bind-Value="text" Size="DSize.Xl" placeholder="Xl" class="w-28" />
<DInput autocomplete="off" @bind-Value="text" Size="DSize.Xs" placeholder="Xs" class="w-28" />
<DInput autocomplete="off" @bind-Value="text" Size="DSize.Sm" placeholder="Sm" class="w-28" />
<DInput autocomplete="off" @bind-Value="text" placeholder="Md" class="w-28" />
<DInput autocomplete="off" @bind-Value="text" Size="DSize.Lg" placeholder="Lg" class="w-28" />
<DInput autocomplete="off" @bind-Value="text" Size="DSize.Xl" placeholder="Xl" class="w-28" />
</div>
<div class="flex flex-wrap items-center gap-2">
<DInput @bind-Value="text" Ghost="true" placeholder="Ghost" />
<DInput @bind-Value="text" disabled placeholder="Disabled" />
<DInput @bind-Value="search" Immediate="true" placeholder="Search">
<DInput autocomplete="off" @bind-Value="text" Ghost="true" placeholder="Ghost" />
<DInput autocomplete="off" @bind-Value="text" disabled placeholder="Disabled" />
<DInput autocomplete="off" @bind-Value="search" Immediate="true" placeholder="Search">
<Prefix><i class="ion-md-search opacity-50"></i></Prefix>
<Suffix><kbd class="kbd kbd-sm">⌘</kbd><kbd class="kbd kbd-sm">K</kbd></Suffix>
</DInput>
<DInput @bind-Value="site" Color="DColor.Primary">
<DInput autocomplete="off" @bind-Value="site" Color="DColor.Primary">
<Prefix><DLabel>https://</DLabel></Prefix>
<Suffix><DLabel>.com</DLabel></Suffix>
</DInput>
</div>
<p class="text-sm opacity-60">Immediate search: "@search", text: "@text"</p>
<div class="flex flex-col">
<DInput @bind-Value="model.Name" Validator="true" placeholder="Name (required)" class="w-full max-w-xs" />
<DInput autocomplete="off" @bind-Value="model.Name" Validator="true" placeholder="Name (required)" class="w-full max-w-xs" />
<DValidatorHint>Name is required</DValidatorHint>
</div>
</section>
@@ -82,22 +82,22 @@
<div class="grid grid-cols-3 gap-2">
@foreach (var color in Enum.GetValues<DColor>())
{
<DTextarea @bind-Value="bio" Color="color" placeholder="@color.ToString()" class="min-h-0 h-12" />
<DTextarea autocomplete="off" @bind-Value="bio" Color="color" placeholder="@color.ToString()" class="min-h-0 h-12" />
}
</div>
<div class="flex flex-wrap items-start gap-2">
<DTextarea @bind-Value="bio" Size="DSize.Xs" placeholder="Xs" class="w-24" />
<DTextarea @bind-Value="bio" Size="DSize.Sm" placeholder="Sm" class="w-24" />
<DTextarea @bind-Value="bio" placeholder="Md" class="w-24" />
<DTextarea @bind-Value="bio" Size="DSize.Lg" placeholder="Lg" class="w-24" />
<DTextarea @bind-Value="bio" Size="DSize.Xl" placeholder="Xl" class="w-24" />
<DTextarea autocomplete="off" @bind-Value="bio" Size="DSize.Xs" placeholder="Xs" class="w-24" />
<DTextarea autocomplete="off" @bind-Value="bio" Size="DSize.Sm" placeholder="Sm" class="w-24" />
<DTextarea autocomplete="off" @bind-Value="bio" placeholder="Md" class="w-24" />
<DTextarea autocomplete="off" @bind-Value="bio" Size="DSize.Lg" placeholder="Lg" class="w-24" />
<DTextarea autocomplete="off" @bind-Value="bio" Size="DSize.Xl" placeholder="Xl" class="w-24" />
</div>
<div class="flex flex-wrap items-start gap-2">
<DTextarea @bind-Value="bio" Ghost="true" placeholder="Ghost" />
<DTextarea @bind-Value="bio" disabled placeholder="Disabled" />
<DTextarea autocomplete="off" @bind-Value="bio" Ghost="true" placeholder="Ghost" />
<DTextarea autocomplete="off" @bind-Value="bio" disabled placeholder="Disabled" />
</div>
<div class="flex flex-col">
<DTextarea @bind-Value="model.Bio" Immediate="true" Validator="true" placeholder="Bio, 10 to 140 characters" class="w-full" />
<DTextarea autocomplete="off" @bind-Value="model.Bio" Immediate="true" Validator="true" placeholder="Bio, 10 to 140 characters" class="w-full" />
<DValidatorHint>Between 10 and 140 characters</DValidatorHint>
<span class="text-sm opacity-60">@(model.Bio?.Length ?? 0)/140</span>
</div>
@@ -286,21 +286,21 @@
<Legend>Page details</Legend>
<ChildContent>
<DLabel>Title</DLabel>
<DInput @bind-Value="text" placeholder="My awesome page" />
<DInput autocomplete="off" @bind-Value="text" placeholder="My awesome page" />
<DLabel>Slug</DLabel>
<DInput @bind-Value="site" placeholder="my-awesome-page" />
<DInput autocomplete="off" @bind-Value="site" placeholder="my-awesome-page" />
</ChildContent>
<Description>You can edit these later</Description>
</DFieldset>
<DFieldset disabled>
<Legend>Disabled fieldset</Legend>
<ChildContent><DInput @bind-Value="text" placeholder="Can't type here" /></ChildContent>
<ChildContent><DInput autocomplete="off" @bind-Value="text" placeholder="Can't type here" /></ChildContent>
</DFieldset>
<DLabel Floating="true" Text="Your email">
<DInput @bind-Value="email" placeholder="mail@site.com" />
<DInput autocomplete="off" @bind-Value="email" placeholder="mail@site.com" />
</DLabel>
<DLabel Floating="true" Text="Notes">
<DTextarea @bind-Value="bio" placeholder="Notes" />
<DTextarea autocomplete="off" @bind-Value="bio" placeholder="Notes" />
</DLabel>
</section>
@@ -308,11 +308,11 @@
<h3 class="text-lg font-bold">DValidator / DValidatorHint</h3>
<p class="text-sm opacity-60">Native rules, checked by the browser once you've edited the field.</p>
<div class="flex flex-col">
<DInput @bind-Value="email" Type="email" Validator="true" required="true" placeholder="mail@site.com" />
<DInput autocomplete="off" @bind-Value="email" Type="email" Validator="true" required="true" placeholder="mail@site.com" />
<DValidatorHint>Enter a valid email address</DValidatorHint>
</div>
<div class="flex flex-col">
<DInput @bind-Value="username" Validator="true" required="true" pattern="[A-Za-z][A-Za-z0-9\-]*" minlength="3"
<DInput autocomplete="off" @bind-Value="username" Validator="true" required="true" pattern="[A-Za-z][A-Za-z0-9\-]*" minlength="3"
maxlength="30" placeholder="Username" title="Only letters, numbers or dash" />
<DValidatorHint Compact="true">Must be 3 to 30 letters, numbers or dashes (Compact: no space while valid)</DValidatorHint>
</div>
@@ -375,7 +375,7 @@
string framework;
DColor? filterColor;
string otp;
List<string> files = new();
List<string> files = [];
void Submitted(bool valid) => submitResult = valid ? "Valid" : "Invalid: see the messages above";
+1 -1
View File
@@ -216,7 +216,7 @@
record Toast(int Id, DAlertColor Color);
readonly List<Toast> toasts = new();
readonly List<Toast> toasts = [];
DToastHorizontal toastHorizontal;
DToastVertical toastVertical;
int toastCount;
+1 -1
View File
@@ -165,7 +165,7 @@
<DButton class="join-item">from lg</DButton>
</DJoin>
<DJoin>
<input class="input join-item" placeholder="Email" />
<input autocomplete="off" class="input join-item" placeholder="Email" />
<select class="select join-item w-28">
<option>Filter</option>
<option>Sci-fi</option>