Inbox with undo
An inbox where each Archive button posts a form and a toast offers Undo. On static pages the post redirects with the archived ids in the address, the toast follows, and Undo is a link; on interactive pages Undo runs C#. Works with JavaScript off.
ToastService Toasts
NavigationManager Navigation
<ToastRegion />
<Card Title="Inbox" Description="($"{Shown.Count} conversations")" Grow MaxWidth="40.Rem">
<CardBody Flush>
if (Shown.Count == 0)
{
<EmptyState Icon="Lucide.Inbox" Title="All caught up" Description="Archived conversations stay in Archive for 30 days." Size="ControlSize.Sm" />
}
else
{
<form method="post" @formname="inbox" @onsubmit="Archive" data-enhance>
<AntiforgeryToken />
foreach (var message in Shown)
{
<Row Gap="3" Align="Align.Center" PaddingX="4" PaddingY="3" BorderTop="1">
<Avatar Name="message.From" Decorative />
<Stack Gap="0" Grow MinWidth="0.Rem">
<TextBlock Weight="FontWeight.Medium" Truncate>message.From</TextBlock>
<TextBlock Size="TextSize.Sm" Color="Color.Muted" Truncate>message.Subject</TextBlock>
</Stack>
@* On phones the button is the icon alone, which keeps "Archive" as its name. *@
<Button Submit Name="archive" Value="message.Id" OnClick="() => _clicked = message.Id" VisibleFrom="Breakpoint.Sm"
Size="ControlSize.Sm" Variant="ButtonVariant.Ghost" Accent="Accent.Neutral" StartIcon="Lucide.Archive">Archive</Button>
<IconButton Submit Name="archive" Value="message.Id" OnClick="() => _clicked = message.Id" HiddenFrom="Breakpoint.Sm" Shrink="false"
Size="ControlSize.Sm" Variant="ButtonVariant.Ghost" Accent="Accent.Neutral" Icon="Lucide.Archive" Label="Archive" />
</Row>
}
</form>
}
</CardBody>
</Card>
{
// Static pages: the archived ids live in the address (?archived=…), so each page load knows them.
[SupplyParameterFromQuery(Name = "archived")]
private string[]? ArchivedQuery { get; set; }
// The pressed button's Name=Value posts with the form.
[SupplyParameterFromForm(FormName = "inbox", Name = "archive")]
private string? Posted { get; set; }
// Interactive pages post nothing, so OnClick says which button it was.
private string? _clicked;
private HashSet<string> _archived = [];
private List<Conversation> Shown => Conversations.Where(c => !_archived.Contains(c.Id)).ToList();
protected override void OnParametersSet() => _archived = [.. ArchivedQuery ?? []];
private void Archive()
{
var conversation = Conversations.FirstOrDefault(c => c.Id == (Posted ?? _clicked));
if (conversation is null)
{
return;
}
var undo = UrlWith(_archived.Where(id => id != conversation.Id));
_archived.Add(conversation.Id);
Toasts.Info($"Archived \"{conversation.Subject}\".", action: new("Undo", Href: undo)
{
OnClick = () =>
{
_archived.Remove(conversation.Id);
StateHasChanged();
return Task.CompletedTask;
},
});
// A static post redirects to the new address, and the toast follows it.
if (!RendererInfo.IsInteractive)
{
Navigation.NavigateTo(UrlWith(_archived));
}
}
private string UrlWith(IEnumerable<string> archived) =>
Navigation.GetUriWithQueryParameters(new Dictionary<string, object?> { ["archived"] = archived.ToArray() });
}Static: Archive posts without a reload, and the toast closes itself after a few seconds.