Files
decePubClient/Components/Admin/AnnouncementsPanel.razor
T

245 lines
9.1 KiB
Plaintext
Raw Normal View History

@inherits LocalizableComponentBase
@using System.Globalization
@* The administrators' announcements (/clientapi/admin/announcements): written in Markdown, with when they run and
whether they are all day, kept as drafts until published; shown to every persona above its feed while published and
not ended. Each says how many personas dismissed it and how they reacted. Deleting one takes its reads and reactions
with it, and every persona's stream hears it go. *@
<div class="flex flex-col gap-3 w-full" data-announcements>
<div class="flex flex-wrap items-center gap-2">
<DButton Size="DSize.Sm" Color="DColor.Primary" Disabled="IsDefaultDisabled" OnClick="New" data-announcement-new>
<i class="ion-md-create" aria-hidden="true"></i><span>@Localizer["Write an announcement"]</span>
</DButton>
</div>
@if (IsLoading)
{
<p class="flex w-full items-center justify-center gap-2">
<DLoading Variant="DLoadingVariant.Dots" LabelText="@Localizer["loading"]" />
@Localizer["loading"]
</p>
}
else if (announcements.Count == 0)
{
<p class="text-sm opacity-70" data-announcements-empty>@Localizer["No announcement yet."]</p>
}
@foreach (var announcement in announcements)
{
<article class="flex flex-col gap-2 p-3 rounded-xl neo-raised neo-xs" data-announcement="@announcement.Id" data-announcement-state="@StateOf(announcement)">
<div class="flex flex-wrap items-center gap-2">
@switch (StateOf(announcement))
{
case "shown":
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft" Color="DColor.Success">@Localizer["Shown now"]</DBadge>
break;
case "ended":
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft">@Localizer["Ended"]</DBadge>
break;
default:
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft" Color="DColor.Warning">@Localizer["Draft"]</DBadge>
break;
}
@if (announcement.AllDay)
{
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Outline">@Localizer["All day"]</DBadge>
}
<span class="text-xs opacity-70" title="@announcement.CreatedAt.ToLocalTime().ToString(SUtility.dateTimeFormatExt)">
@Localizer["Written {0} ago", announcement.CreatedAt.GetPassedTime(Localizer)]
</span>
<span class="flex-1"></span>
<DButton Size="DSize.Sm" Disabled="IsDefaultDisabled || busy.Contains(announcement.Id)" OnClick="() => Edit(announcement)" data-announcement-edit>
<i class="ion-md-create" aria-hidden="true"></i><span>@Localizer["Edit"]</span>
</DButton>
<DButton Size="DSize.Sm" Disabled="IsDefaultDisabled || busy.Contains(announcement.Id)" OnClick="() => Delete(announcement)" data-announcement-delete>
<i class="ion-md-trash text-error" aria-hidden="true"></i><span>@Localizer["Delete"]</span>
</DButton>
</div>
@if (announcement.StartsAt is not null || announcement.EndsAt is not null)
{
<p class="text-xs opacity-70">@When(announcement)</p>
}
<div class="post-content doc-content text-sm break-words">@((MarkupString)announcement.Html)</div>
<div class="flex flex-wrap items-center gap-2 text-xs">
<span class="opacity-70" data-announcement-reads="@announcement.Reads">@Localizer["Dismissed by {0}", announcement.Reads]</span>
@foreach (var reaction in announcement.Reactions)
{
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft" data-announcement-reaction="@reaction.Name">
@reaction.Name <span class="tabular-nums">@reaction.Count</span>
</DBadge>
}
</div>
</article>
}
</div>
@if (editing is not null)
{
<DModal Placement="DModalPlacement.ResponsiveBottom" IsOpen="true" IsOpenChanged="Closed" CloseText="@Localizer["Close"]"
data-announcement-dialog>
<Heading>@(editing.Id is null ? Localizer["New announcement"] : Localizer["Edit the announcement"])</Heading>
<ChildContent>
<EditForm Model="editing" OnValidSubmit="Save" class="flex flex-col gap-3 mt-3">
<DataAnnotationsValidator />
<label class="flex flex-col gap-1">
<span class="text-sm">@Localizer["What it says (Markdown)"]</span>
<DTextarea TValue="string" class="w-full" @bind-Value="editing.Text" Immediate="true" rows="6" autocomplete="off" maxlength="5000"
data-announcement-text />
</label>
<div class="flex flex-wrap gap-3 pointer-coarse:gap-2">
<label class="flex flex-col gap-1">
<span class="text-sm">@Localizer["From"]</span>
<DInput TValue="string" Type="datetime-local" class="w-auto" @bind-Value="startsAt" autocomplete="off" data-announcement-starts />
</label>
<label class="flex flex-col gap-1">
<span class="text-sm">@Localizer["Until"]</span>
<DInput TValue="string" Type="datetime-local" class="w-auto" @bind-Value="endsAt" autocomplete="off" data-announcement-ends />
</label>
</div>
<p class="text-xs opacity-70">@Localizer["Both are optional: once its end is past, it is no longer shown."]</p>
<DToggle LabelText="@Localizer["All day"]" @bind-Value="editing.AllDay" data-announcement-all-day />
<DToggle LabelText="@Localizer["Published: shown to every persona"]" @bind-Value="editing.IsPublished" data-announcement-published />
<ErrorViewer AdditionalError="@error" />
<div class="flex justify-end gap-2">
<DButton OnClick="() => editing = null" data-announcement-cancel>@Localizer["Cancel"]</DButton>
<DButton Type="submit" Color="DColor.Primary" Disabled="IsDefaultDisabled || isSaving" data-announcement-save>
@if (isSaving)
{
<DLoading Size="DSize.Sm" LabelText="@Localizer["loading"]" />
}
<span>@Localizer["Save"]</span>
</DButton>
</div>
</EditForm>
</ChildContent>
</DModal>
}
@code {
const string LocalInput = "yyyy-MM-ddTHH:mm";
[Inject] InstanceAdminService InstanceAdminService { get; set; }
[Inject] ConfirmService Confirm { get; set; }
List<ViewAnnouncement> announcements = [];
// the announcement being written or changed, with its times as the datetime-local inputs hold them (the reader's own)
AnnouncementForm editing;
string startsAt;
string endsAt;
string error;
bool isSaving;
HashSet<string> busy = [];
protected override async Task OnInitializedAsync()
{
if (CascadingState.IsOffline)
{
WhenOnline(LoadData);
StartOnlinePolling();
IsLoading = false;
return;
}
await LoadData();
IsLoading = false;
}
async Task LoadData() => announcements = await InstanceAdminService.Announcements();
static string StateOf(ViewAnnouncement announcement) => announcement.IsShown ? "shown" : announcement.IsPublished ? "ended" : "draft";
string When(ViewAnnouncement announcement)
{
var format = announcement.AllDay ? SUtility.dateTimeFormat : SUtility.dateTimeFormatExt;
var starts = announcement.StartsAt?.ToLocalTime().ToString(format);
var ends = announcement.EndsAt?.ToLocalTime().ToString(format);
return (starts, ends) switch
{
({ } from, { } to) => Localizer["From {0} to {1}", from, to],
({ } from, null) => Localizer["From {0}", from],
_ => Localizer["until {0}", ends]
};
}
void Closed(bool open)
{
if (!open)
editing = null;
}
void New()
{
error = null;
editing = new AnnouncementForm();
startsAt = null;
endsAt = null;
}
void Edit(ViewAnnouncement announcement)
{
error = null;
editing = new AnnouncementForm
{
Id = announcement.Id,
Text = announcement.Text,
StartsAt = announcement.StartsAt,
EndsAt = announcement.EndsAt,
AllDay = announcement.AllDay,
IsPublished = announcement.IsPublished
};
startsAt = announcement.StartsAt?.ToLocalTime().ToString(LocalInput, CultureInfo.InvariantCulture);
endsAt = announcement.EndsAt?.ToLocalTime().ToString(LocalInput, CultureInfo.InvariantCulture);
}
// a datetime-local value, the reader's own time, in UTC; null when empty or not a time
static DateTime? Utc(string local) =>
DateTime.TryParse(local, CultureInfo.InvariantCulture, DateTimeStyles.AssumeLocal, out var at) ? at.ToUniversalTime() : null;
async Task Save()
{
error = null;
editing.StartsAt = Utc(startsAt);
editing.EndsAt = Utc(endsAt);
if (editing.StartsAt is { } from && editing.EndsAt is { } to && to < from)
{
error = Localizer["An announcement can't end before it starts."];
return;
}
isSaving = true;
var result = await InstanceAdminService.SaveAnnouncement(editing);
isSaving = false;
if (result.IsInvalid)
{
error = result.ErrorMessage;
return;
}
var saved = (ViewAnnouncement)result.Data;
var at = announcements.FindIndex(a => a.Id == saved.Id);
if (at < 0)
announcements.Insert(0, saved);
else
announcements[at] = saved;
editing = null;
Toast.AddResponseSuccess(Localizer["The announcement is saved."]);
}
async Task Delete(ViewAnnouncement announcement)
{
if (!await Confirm.Ask(Localizer["Delete the announcement?"], Localizer["It disappears for everyone, with its reactions."], Localizer["Delete"], true))
return;
busy.Add(announcement.Id);
var result = await InstanceAdminService.DeleteAnnouncement(announcement.Id);
busy.Remove(announcement.Id);
if (result.IsInvalid)
{
Toast.AddResponseError(result);
return;
}
announcements.Remove(announcement);
Toast.AddResponseSuccess(Localizer["The announcement is deleted."]);
}
}